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:

SiteBinds
Page component / app-nav visiblerecord, current_user, user, ctx.user, os.user, app, features, page.* (objectui app-shell/src/providers/ExpressionProvider.tsx:59,70)
Per-option visibleWhenrecord + the host predicate scope (objectui core/src/evaluator/optionRules.ts:103)
Form section / form field visibleWhenrecord + previousonly — 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

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions