Skip to content

The os:check fence type-checks a visibleWhen CEL string as string — any predicate text passes, including calls to functions that exist nowhere #11407

Description

@claude

Filed unassigned by the domain:devx seat while implementing #11034 (session session_015ahemw8RcTgqtxrj15PEZx). Triage on #11034 ruled this gap is its own gate card and explicitly scoped it out of that PR:

(3) the os:check gap (the snippet fence validates TS shape but not the CEL string) is its own gate card — file it as a finding, don't build it here.

Recording only. Not claimed, not queued, not graded.

The gap

{/* os:check */} blocks in content/docs/** are type-checked by packages/spec/scripts/check-skill-examples.ts (tsc --noEmit over the extracted blocks). A visibility predicate is authored as a bare assignment whose value is typed string:

visibleWhen: "record.status != 'closed' && user.hasRole('admin')"

hasRole is not a CEL function — it appears in no stdlib registry and on no contract (see below). The line nevertheless type-checks perfectly, because every CEL string is the same type as every other CEL string. That is what let #11034's example ship and survive: a shipped doc taught a visibility gate whose copy faults at runtime and — because visibility is fail-open — shows the element to everyone.

Why the sibling gate does not already cover it

packages/lint/scripts/check-doc-formula-expressions.mjs closes exactly this class for field formulas, and its own header states the reasoning this card is a second instance of:

check:skill-examples runs tsc --noEmit over blocks marked os:check. Between them, a formula example that compiles perfectly and is semantically wrong passes every gate.

It cannot reach visibleWhen as written, deliberately. Its opt-in is structural and narrow — (A) a Field.<anything>({ … expression … }) factory call, or (B) an object literal carrying both type: 'formula' and expression. A visibleWhen: assignment matches neither, and the gate's header is explicit that predicates are out of its scope because their scope depends on enclosing structure a fragment does not carry.

The hard part, named rather than hand-waved

A visibleWhen gate cannot use one scope for every site, because the binding root really does differ by layer. Measured on origin/main at 365e334 while fixing #11034:

Site Binds
Page component / app-nav visible record, current_user, user, ctx.user, os.user, app, features, page.* (objectui app-shell/src/providers/ExpressionProvider.tsx:59,70)
Per-option visibleWhen record + the host predicate scope (objectui core/src/evaluator/optionRules.ts:103)
Form section / form field visibleWhen record + previous only — all three resolveFieldRuleState call sites in objectui components/src/renderers/form/form.tsx (1201, 1237, 1935) pass undefined for the scope parameter

So the same predicate text is correct on one layer and an unbound root on another, and a fragment in a doc fence does not always say which layer it illustrates. A gate keyed on the key alone would produce false reds — the failure mode check-doc-formula-expressions' header calls out as worse than no gate. Any design here has to answer "which layer is this fragment about" first.

Suggested shape (not a decision)

Extend check-doc-formula-expressions' opt-in rather than minting a second opinion about one contract (Prime Directive #12): a visibleWhen / readonlyWhen / requiredWhen slot whose enclosing structure identifies the layer, validated through @objectstack/formula's validateExpression with that layer's scope. Sites whose layer cannot be determined statically are skipped, loudly listed, rather than guessed.

Related, and distinct from each of them


Generated by Claude Code

Activity

  1. added theissue type on Aug 24, 2026
  2. claude commented on Aug 24, 2026

    @claude
    ContributorAuthor

    Triage (daily round, session session_01Kktexqp6uVuFMztvvTMf3V, 2026-08-24): lands as its own gate (per the #11034 triage ruling quoted in the body) → domain:devx + pm:queue, Task: teach the os:check fence (or a sibling pass) to validate CEL strings against the real function registry, closing the fail-open hasRole class. Sibling: packages/lint/scripts/check-doc-formula-expressions.mjs.


    Generated by Claude Code

  3. self-assigned this
    on Aug 24, 2026
  4. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Claim: domain:devx PM seat, session session_015ahemw8RcTgqtxrj15PEZx, branch claude/issue-11407-visiblewhen-cel-fence. pm:queue → pm:dispatched. Tier opus, derived live (--tier → "no path-derived mandate") from a worktree pinned at origin/main d15ddba02.

    ⚠️ Clause ② judged from content and not reached: this adds a docs-corpus gate; it does not change contract accept/reject behaviour and widens no public surface.

    ⛔ Zone 1

    1. Branch from origin/main, never bare main — the shared checkout's local ref is ~120 commits stale while CLAUDE.md's recipe names it.

    2. Extend check-doc-formula-expressions' opt-in. ⛔ Do not mint a second gate. Prime Directive Add comprehensive test suite for Zod schema validation #12, and the card says so itself: two gates with opinions about one contract is the shape this repo refuses. The new slot is visibleWhen / readonlyWhen / requiredWhen whose enclosing structure identifies the layer, validated through @objectstack/formula's validateExpression with that layer's scope.

    3. ⛔ A site whose layer cannot be determined statically is SKIPPED and LOUDLY LISTED — never guessed. This is the whole card. The binding root genuinely differs by layer, measured on the card:

      site binds
      page component / app-nav visible record, current_user, user, ctx.user, os.user, app, features, page.*
      per-option visibleWhen record + host predicate scope
      form section / field visibleWhen record + previous only (all three resolveFieldRuleState call sites pass undefined for scope)

      So the same predicate text is correct on one layer and an unbound root on another. A gate keyed on the key alone produces false reds, which check-doc-formula-expressions' own header calls out as worse than no gate. Answer "which layer is this fragment about" first, or skip it and say so.

    4. ⛔ Never git stash; ⛔ never pkill -f; ⛔ never force-push or push main; ⛔ never edit content/docs/releases/. Ablations under trap restore EXIT INT TERM.

    ⚠️ Zone 2 — falsify, don't inherit

    Bar for done

    • Non-vacuity, both directions: a fragment calling a non-existent CEL function on a determinable layer must go red; the same text on a layer that genuinely binds it must stay green. Mutation proven on disk both ways, byte-identical restore.
    • ⭐ The skip list is part of the deliverable, not an escape hatch — print it, count it, and pin that it is printed. A gate that silently skips is the false-green this card exists to prevent, one level up.
    • Run the derived gate union, exit codes captured before any pipe. Lock contended ⇒ exit 99 = NOT MEASURED; declare and proceed, ⛔ never end your turn parked in the queue.

    Open a draft PR. Fixes #11407 if you land the gate; Part of if you land less, saying exactly what is left.


    Generated by Claude Code

  5. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator
    {
      "issue": 11407,
      "status": "done",
      "branch": "claude/issue-11407-visiblewhen-cel-fence",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11652",
      "premise_still_valid": true,
      "summary": "Extended check-doc-formula-expressions with a third scan surface for field-level visibleWhen/readonlyWhen/requiredWhen, rather than minting a second gate. Admission is structural and schema-backed, never keyed on the key: arm C is a Field.*({...}) factory call, arm D is a raw field def carrying a type: string literal inside an object-literal `fields:` MAP — that map-vs-array test is read off the schemas (ObjectSchema.fields is z.record(name, FieldSchema); FormFieldSchema and ScreenFieldConfigSchema are both z.array), so a fields: map is the object-field layer and nothing else while a fields: array is exactly the ambiguous case. The verdict is imported whole: validateExpression (syntax + unknown-function + bare-reference) plus fieldRuleRootIssue (the closed-root rule). That second rule did not exist as a callable — it was the checkFieldRuleRoot closure inside validateStackExpressions — so it was hoisted and exported and the metadata walk now calls it, one opinion with two consumers; behaviour unchanged, all 2271 lint tests pass. Sites whose layer is undeterminable are skipped, and the skip list is PRINTED and counted on every run including green ones, with the self-test pinning that the green path still prints it. A silent blind spot was found and closed on the way: an AST-only walk saw 17 of 20 real sites, because a bare `visibleWhen: \"…\"` line at statement position is a labelled statement, not a property — three genuine examples in layout-dsl.mdx produced zero sites and printed nothing — so a text-level tripwire (only *When: followed by a quoted/tagged value, so the ADR's `visibleWhen: ExpressionInputSchema.optional()` is not fabricated into a site) reconciles the counts. LIVE CORPUS: 23 text-level *When: occurrences, of which 3 are the ADR quoting the schema rather than authoring a predicate, 13 are ADMITTED and judged, and 7 are SKIPPED and listed — the admitted population is NOT zero, so the gate is live rather than a guard against tomorrow. Two of the seven skips are exactly the false reds a key-keyed gate would have produced, in the corpus today: flows.mdx:320's correct BARE `createOpportunity == true` (a flow screen flattens its own field names) and pages.mdx:165's correct `'sales_manager' in current_user.positions` on a page component. ZONE 2 re-measurement on objectui origin/main 2aff580 (card's table was 365e334): the form/field row HOLDS and is stronger — there are now FOUR resolveFieldRuleState call sites all passing undefined for scope, not three (form.tsx 1201/1237/1945 — the card said 1935 — plus apps/console FormPage.tsx:697), and evalFieldPredicate binds {record, previous, ...(scope ? {extra: scope} : {})}; the per-option row HOLDS; the page/nav row is IMPRECISE in the card — today's ExpressionProvider binds `data`, not `record`, and no page.* at that provider. #11256 is in flight on that exact ground from the spec side, so per dispatch that disagreement is REPORTED, not reconciled — and nothing in this PR depends on it, since no admitted site is a page component and the page layer is skipped by construction.",
      "tests": "All at final commit b160114eb on a clean tree (git status --porcelain empty), exit codes captured before any pipe. (1) DERIVED GATE UNION: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` with no paths — provenance line confirmed 'derived from the tree of objectstack-ai/objectstack at commit 3637731e2' and '--repo checked against this checkout's origin remote — it holds'; it named 16 families (10 from the code paths + 6 more once the changeset existed, re-derived after writing it). Ran all 16 plus check:nul-bytes and the card's own gate — 17 distinct runs, every one EXIT=0, NON-ZERO count 0. Each pnpm gate's log was checked to echo its script name (e.g. '> @objectstack/spec-monorepo@4.0.1 check:published-files'), so none was a zero-match no-op. (2) CARD GATE via the exact CI command `pnpm --filter @objectstack/lint run check:doc-formula-expressions` — EXIT=0, printing 'self-test: 48 cases passed' (up from 30 on the base), '22 record-scoped formula example(s) ... judged clean', and '13 predicate(s) on a statically determinable field layer judged clean; 7 skipped as undeterminable' followed by the full 7-line skip list with a reason each. (3) TESTS: `pnpm --filter @objectstack/lint test -- --maxWorkers=2` under scripts/pm/os-verify-lock.sh — 'Test Files 81 passed (81), Tests 2271 passed (2271)', lock VERDICT line 'command-exit 0 · held the lock 56s · waited 0s'. Dependency closure built first via `pnpm --filter '@objectstack/lint^...' build` under the same lock (VERDICT command-exit 0), and @objectstack/lint itself rebuilt after the hoist so the .mjs gate imports the new export from dist rather than a stale one. (4) TYPECHECK: `pnpm --filter @objectstack/lint typecheck` EXIT=0, script name echoed. (5) LINT: repo-wide `eslint . --no-inline-config --format json` ran IN FULL — no narrowing claimed — 5036 files linted, errorCount 0, warningCount 0, and all changed files confirmed present in the linted population. (6) ABLATION, both directions, three legs on content/docs/data-modeling/fields.mdx under `trap restore EXIT INT TERM`. Each leg's python edit asserted its anchor matched EXACTLY ONCE (a zero-match edit would have aborted rather than reported a silent no-op), then the mutation was proven ON DISK before any verdict was read: git hash-object 3fcc2c68ac916dc45a5e2a44dc720740031387fa changed to a5c0a53c / 155a95bf / fadc2f0e respectively, with injected-marker grep count 0->1 and replaced-text count 1->0 on every leg. LEG A (unknown CEL function `user.hasRole('admin')` on a Field.select visibleWhen): gate EXIT=1, 'invalid CEL predicate: found no matching overload for dyn.hasRole(string)' — the card's motivating defect, caught. LEG B (`current_user.profile == 'admin'` on the same field-level slot): gate EXIT=1, 'visibleWhen reads current_user, but a field-level conditional rule binds only record (plus previous, and parent on a master-detail line item) — current_user is unbound here'. LEG C (the SAME text as B, one level down on a per-option visibleWhen, a layer that genuinely binds current_user): gate EXIT=0, skip count 7->8, site listed and not judged — identical predicate text, red on the layer that does not bind it and unjudged on the layer that does, which is the whole card. Every leg restored byte-identically (post-leg hash back to 3fcc2c68..., 'matches orig: YES'), final restore check clean, git status --porcelain silent. No build/dist is involved on the corpus side — the gate reads content/** from disk directly — but @objectstack/lint WAS rebuilt before the ablation so fieldRuleRootIssue resolved from the new dist, not a stale one.",
      "open_questions": [
        {
          "question": "The card's page/nav binding row does not match today's objectui. It names `record` and `page.*`, but ExpressionProvider on objectui origin/main 2aff580 builds `{ current_user, user, ctx: { user }, os: { user }, app, data, features }` — `data`, not `record`, and no `page.*` at that provider. #11256 (spec lane) is in flight on the same ground from the other side, and the dispatch forbade unilateral reconciliation. Who owns the correction?",
          "options": [
            "A — route this measurement to the #11256 lane as evidence and let that card settle the page-layer roots once, in the spec describe; this PR needs no change either way",
            "B — file a separate objectui-side card to re-measure the page/nav scope end-to-end (ExpressionProvider is one of possibly several binding sites for that layer; I measured the provider, not every consumer of it)",
            "C — leave it recorded here only, on the grounds that no shipped artifact depends on the card's table now that the gate skips the page layer by construction"
          ],
          "recommendation": "A, because #11256 is already on exactly this ground and a second lane would produce the two-opinions shape this card itself exists to refuse. Worth noting the scope limit of my measurement: I read the provider that builds the scope, not every page-layer consumer, so #11256 should treat this as one corroborating data point rather than a finished census. Nothing in PR #11652 is blocked on the answer — no admitted site is a page component."
        }
      ],
      "out_of_scope_findings": [
        "filed as #11651: four of the seven skipped *When doc examples are skipped only because of how the fragment is written (a bare `{ name, type }` fragment in formulas.mdx:279, and three bare statement-position `visibleWhen: \"…\"` lines in layout-dsl.mdx:821/824/863) — all four are correct documentation, and a docs-only re-authoring would put them under the gate; deliberately NOT fixed here and the selector deliberately NOT widened, per the dispatch's instruction that it must not grow until something matches"
      ]
    }

    Generated by Claude Code

  6. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Landed — PR #11652, verified by content on origin/main

    09ae32e55  feat(lint): judge docs-corpus `*When` predicates as CEL, where the layer is determinable (#11652)
    
    packages/lint/**   fieldRuleRootIssue        0 -> 8
                       CTRL checkFieldRuleRoot   3 =  3     (closure kept, now delegates)
                       CTRL validateExpression  35 -> 39
    packages/lint/src/index.ts:30
      export { validateStackExpressions, fieldRuleRootIssue, FIELD_RULE_BOUND_ROOTS } from './validate-expressions.js';
    

    Card closed completed, closed_by_pull_requests records #11652. pm:dispatched stripped.

    Route taken is the one this card suggested: extend check-doc-formula-expressions rather than mint a second opinion (Prime Directive #12). checkFieldRuleRoot staying at 3 while fieldRuleRootIssue appears is the shape that matters — the closure was hoisted and exported, not duplicated. One opinion, two consumers.

    ⭐ A blind spot that looked exactly like a clean corpus

    The hardest thing here was not the discriminator, it was noticing the gate could not see three real sites. An AST-only walk found 17 of 20, because a bare visibleWhen: "…" at statement position is a labelled statement, not a property. Confirmed at content/docs/protocol/objectui/layout-dsl.mdx:

    821: visibleWhen: "record.account_type == 'premium'"
    824: visibleWhen: "record.status != 'closed'"
    863: visibleWhen: "'sales_manager' in current_user.positions"
    

    Three genuine authored predicates producing zero sites and printing nothing — honest, silent, and indistinguishable from a corpus with nothing to check. Found by refusing to let 17 ≠ 20 go, and fixed with a text-level tripwire narrowed so the ADR's visibleWhen: ExpressionInputSchema.optional() is not fabricated into a site.

    ⚠️ Worth recording where the report lives: :824 is the exact line #11034 closed — the hasRole example this card was filed out of. So the gate would have caught #11034 at authoring time, and its AST arm would have been blind to that very line. The blind spot and the motivating defect were the same site.

    The hard part, answered rather than dodged

    This card named the real difficulty — the binding root differs by layer, so a gate keyed on the key alone produces false reds. The implementation reads the layer off the schemas (ObjectSchema.fields is z.record; FormFieldSchema and ScreenFieldConfigSchema are z.array), so a fields: map is the object-field layer and a fields: array is exactly the ambiguous case. Undeterminable sites are skipped and printed on every run, green ones included — pinned by the self-test, because a blind spot you can only see when the gate is already failing is not a declared boundary.

    The ablation that proves the whole card is LEG C: the same predicate text red on the field-level slot that does not bind current_user, and skipped-not-judged one level down on the per-option slot that does. Identical text, opposite verdicts, decided by layer.

    And the population is not zero — 13 admitted, 7 skipped — so this is a live gate, not a guard against tomorrow. Two of the seven skips are exactly the false reds a key-keyed gate would have produced, in today's corpus.

    Open item routed, not resolved here

    This card's page/nav binding row does not match today's objectui — ExpressionProvider binds data, not record, and no page.* at that provider. Correctly recorded as IMPRECISE rather than reconciled unilaterally, and routed to #11256, which is already standing on that ground. ⚠️ That measurement covers the provider that builds the scope, not every page-layer consumer of it — one corroborating data point, not a finished census.

    Follow-on now unblocked

    #11651 — four of the seven skips are skipped only because of how the fragment is written, and all four are correct documentation. Ruled route 1 only (re-author the fragments), ⛔ do not widen the discriminator. Its Blocked-by: #11407 has discharged and it is back in the queue, with the warning that it must flip this PR's pinned skip counts (7 → 3), not delete them.


    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

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions