Skip to content

visibility-predicate-syntax calls an over-budget predicate "not valid CEL" and prescribes the dialect — the #7073 defect at the visibility gate #7217

Description

@os-help

Found while implementing #7073 (PR #7209), which fixes the same defect shape at the producer (validateExpression). Out of scope there — #7073's ruling is explicitly "fix once at the producer, not per-consumer", and this gate deliberately does not go through validateExpression (its own docblock, validate-visibility-predicates.ts:396-399: "this function neither parses with an environment of its own nor reaches for celEngine.compile / validateExpression"). So the producer fix does not reach it. Filed unassigned, unlabeled — severity is triage's call.

The defect

packages/lint/src/validate-visibility-predicates.ts, the visibility-predicate-syntax gate (#6253):

// celSyntaxFault, :401
if (parseCelToAst(source) !== null) return null;
const parsed = collectCelRootIdentifiers(source);
const detail = parsed.ok ? 'the expression could not be parsed' : parsed.error.split('\n')[0].trim();

parseCelToAst returns null for a bounds overrun exactly as it does for a syntax fault (that collapse is by design — cel-engine.ts docblock, "Returns null … when the source is empty or does not parse"). So an over-budget visibleWhen — perfect CEL, merely past maxAstNodes 256 — reaches the syntax gate and is reported as:

  • message: visibility predicate is not valid CEL — Exceeded maxAstNodes (256) … — the headline is false. It IS valid CEL.
  • hint (:609, the fallback arm, since no NON_CEL_SPELLINGS token matches a source that has none): Visibility predicates are bare CEL, e.g. \record.status == 'open'`. Spellings from other languages do not parse: write `==` (not `===`), `!=` …`

That hint is the #7073 defect byte for byte in a different file: the author is told to change the dialect, the source is already bare CEL with the right operators, and any obedient rewrite — an LLM author above all — regresses.

Measured

origin/main @ edb4af099, via the same 80-clause conjunction #6833/#7073 use:

$ npx tsx probe.ts
parseCelToAst(over-budget) = null
collectCelRootIdentifiers.ok = false
detail = Exceeded maxAstNodes (256)

So celSyntaxFault returns { detail: 'Exceeded maxAstNodes (256)', token: null } — the error arm and the fallback hint arm, both.

Not the same as #6833's benign divergence

#6833 measured, and its closure ruled, that lint being stricter than the pushdown runtime during the rc grace window is benign and self-healing at GA. That ruling is about the verdict, and it stands. This is about the wording of a verdict everyone agrees should fire: the gate is right to refuse, and wrong about why, in both the sentence that names the fault and the sentence that prescribes the fix.

Shape of the fix (not prescribing)

The gate's docblock gives a reason for not reaching validateExpression — it would widen "does not parse" to "does not type-check" — which is a good reason and does not apply to parseCelToAstWithReason, the reason-carrying entrance that answers the same parse question and additionally names the bound. validate-rls-predicate-enforceability.ts:223 already reads it from this package. So the minimal move looks like: ask parseCelToAstWithReason, and on kind: 'bounds' emit a size message + size hint (name the bound and its value) instead of the syntax headline and the dialect hint; on kind: 'parse' change nothing. PR #7209 has a slot-generic bounds prescription that may be reusable verbatim.

Whether the rule id should also change (a size refusal reported under visibility-predicate-syntax is arguably mislabelled at the id level too, which is #6778's "wrong label" shape) is a separate question worth deciding rather than assuming.

Refs: #7073 / PR #7209 (producer-side fix), #6778 / PR #6831 (RLS-side precedent), #6253 (this gate's origin), #6132 (the bounds-reason entrance).


Generated by Claude Code

Activity

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Triage: pm:queue + domain:spec-tooling.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 10, 2026
  3. os-help commented on Aug 10, 2026

    @os-help
    CollaboratorAuthor

    Claim: PM loop round 4
    Session: session_01KJATVrh6V2ysutYUJigh3B
    Branch: claude/issue-7217-visibility-bounds-verdict
    Worktree: objectstack-issue-7217
    Domain: domain:spec-tooling
    File surface: packages/lint/src/validate-visibility-predicates.ts + its tests. ⛔ packages/formula (the CEL front end) is READ-ONLY — this card consumes parseCelToAstWithReason, it does not change it. Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus — the card leaves one question deliberately open (whether the rule id should change for a size refusal, the #6778 "wrong label" shape), and that is a judgement, not a move.
    Serial constraints cleared: validate-visibility-predicates.ts last touched by 58451f3b3 (#6662 via PR #7186) — merged tonight. No open PR touches this file: the adjacent in-flight work from the sibling session is PR #7235 (packages/lint/scripts/check-doc-formula-expressions.mjs) and PR #7209 (#7073's producer-side fix in validateExpression), both different files. This seat's own in-flight PRs #7228 / #7238 are scripts/ and packages/spec/scripts/ respectively. Disjoint on all four.

    Premise re-verified live on origin/main, and it produced a warning worth passing on: parseCelToAst is imported at :227 and the collapse point is at :383. The card cites :401, triage cites :376, I read :383 — three readings, three different line numbers, because PR #7186 refactored this file tonight between the filing and now. Nothing is wrong with any of those citations; they are just all stale in different directions. The dispatch will tell the dev to anchor on the text, never on any of the three numbers.

    Triage's key structural claim checks out independently: parseCelToAstWithReason is genuinely available in-package and already read by the sibling rule (validate-rls-predicate-enforceability.ts:157), so the proposed shape needs no new dependency.


    Generated by Claude Code

  4. os-help commented on Aug 10, 2026

    @os-help
    CollaboratorAuthor

    ACCEPT — PR #7267, reviewed against GitHub rather than against the report.

    Verified independently: 4 files (validate-visibility-predicates.ts +229, its test +164, index.ts +1, a changeset). No docs/adr/**, no content/docs/releases/, and packages/formula untouched — so the READ-ONLY constraint held and no scope expansion was needed. The new id is confirmed an addition by the diff itself, not a rename.

    The reverse verification is why this is acceptable, and direction B is the whole point

    My dispatch named the specific way to get this wrong: "A fix that turns every refusal into a size refusal would be green on the first case and is the obvious way to get this wrong." The dev built a test for exactly that, and the two directions separate cleanly:

    A single-direction check would have passed a fix that destroyed the dialect gate. Building B because the trap was named rather than despite it is the behaviour that makes a green report worth reading.

    The open question is answered with a measured compatibility cost

    I asked for a decision on whether the rule id should change, with the compatibility cost, and reserved needs_decision as legitimate. The dev decided — new error-level id visibility-predicate-over-budget, an addition rather than a rename — and measured the cost rather than asserting it was low: the id visibility-predicate-syntax appears repo-wide only inside packages/lint/src plus changesets/changelogs; no config, no docs table, no example, no CI pin, and authoring-rules.ts registers the family rather than individual ids, so the registry needs no change. An addition also means an existing suppression of visibility-predicate-syntax keeps working. That is the #6778 / PR #6831 precedent applied correctly, in-package.

    The hinge was verified by construction, not by convention

    The card's whole fix rests on parseCelToAstWithReason being safe where validateExpression was not. The dev did not settle that by reading the docblock — it established that parseCelToAst IS parseCelToAstWithReason with the reason discarded, same env, same DEFAULT_LIMITS. So the accept/reject set provably does not move, and the docblock's stated reason for avoiding validateExpression/compile (widening "does not parse" into "does not type-check") genuinely does not apply. That is a structural argument rather than a promise, which is the right kind for a gate.

    Also correct in the small: the load-bearing sentence was reused from PR #7209 by copy, never by cross-package import, and the remedies themselves were written visibility-specific because #7209's are deliberately slot-generic. The structured overrun is read per #6223 with an honest limit: null degradation rather than a fabricated number.

    Changeset, not skip-changeset

    The dev wrote a changeset instead of requesting the label, and that is the right call — this PR exports a new const, so something user-visible ships and the label would have been wrong. Both paths are legitimate in this repo and the deciding fact is whether a published package changes; this one does. I am not overriding it.

    Landing: undrafting and queueing wait for the gate-bearing job conclusions to read success. Note that the changeset means Check Changeset should pass on its own merits here, with no label involved.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions