Repository navigation
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
Activity
claude commented
on Aug 24, 2026 claudeboton Aug 24, 2026 – with ClaudeContributorAuthorMore actionsTriage (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 theos:checkfence (or a sibling pass) to validate CEL strings against the real function registry, closing the fail-openhasRoleclass. Sibling:packages/lint/scripts/check-doc-formula-expressions.mjs.
Generated by Claude Code
Claim:
domain:devxPM seat, sessionsession_015ahemw8RcTgqtxrj15PEZx, branchclaude/issue-11407-visiblewhen-cel-fence.pm:queue→pm:dispatched. Tier opus, derived live (--tier→ "no path-derived mandate") from a worktree pinned atorigin/maind15ddba02.⚠️ 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
-
Branch from
origin/main, never baremain— the shared checkout's local ref is ~120 commits stale while CLAUDE.md's recipe names it. -
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 isvisibleWhen/readonlyWhen/requiredWhenwhose enclosing structure identifies the layer, validated through@objectstack/formula'svalidateExpressionwith that layer's scope. -
⛔ 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 visiblerecord,current_user,user,ctx.user,os.user,app,features,page.*per-option visibleWhenrecord+ host predicate scopeform section / field visibleWhenrecord+previousonly (all threeresolveFieldRuleStatecall sites passundefinedforscope)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. -
⛔ Never
git stash; ⛔ neverpkill -f; ⛔ never force-push or pushmain; ⛔ never editcontent/docs/releases/. Ablations undertrap restore EXIT INT TERM.
⚠️ Zone 2 — falsify, don't inherit- The binding table was measured at
365e334in objectui, a sibling repo on its own cadence. Re-verify all three rows on today's objectuimainbefore resting a gate on them — especially the "all three call sites passundefined" claim, which is the row that makes form-layer predicates strictest. ⚠️ finding:PageComponentSchema.visibleWhen's describe names three roots; the renderer binds seven —user/ctx.user/os.user/app/featuresare undocumented on that surface #11256 (spec lane, currently in flight) touches the same ground from the other side — the specdescribeforPageComponentSchema.visibleWhennames three roots where the renderer binds more. ⛔ Do not editpackages/spec; if your measurement disagrees with theirs, report it, don't reconcile it unilaterally.- The card's motivating example (
user.hasRole('admin')inlayout-dsl.mdx) was fixed bylayout-dsl.mdx:824teachesuser.hasRole('admin')inside a CELvisibleWhen, buthasRoleexists nowhere in spec or core — the example faults and fail-softs to visible #11034. Re-check what the corpus contains today — report the live count ofvisibleWhen/readonlyWhen/requiredWhenfragments incontent/docs/**, how many your selector admits, and how many it must skip for undeterminable layer. If the admitted population is zero, that is a real result and the gate is a guard against tomorrow — say so plainly rather than widening the selector until something matches. - Related but distinct, per the card: [finding] Nothing enforces the form-section
nameconvention in content/docs/** examples — os:check passes becausenameis.optional()#10830 (declared-but-.optional()key), [finding] check:skill-examples's marked-block EXTRACTION (not just the orphan scan) is not fence-aware — a fully worked nested example could misfire #11355 (extraction, not semantics), [finding] Three gaps in thevisibleWhengate found by census: one authored-node bypass, a second evaluator with an oppositedatabinding, and total silence on fault in production (all land in objectui) #11258 (runtime, objectui). ⛔ None of them is this card.
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 #11407if you land the gate;Part ofif you land less, saying exactly what is left.
Generated by Claude Code
-
{ "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
Landed — PR #11652, verified by content on
origin/main09ae32e55 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_requestsrecords #11652.pm:dispatchedstripped.Route taken is the one this card suggested: extend
check-doc-formula-expressionsrather than mint a second opinion (Prime Directive #12).checkFieldRuleRootstaying at 3 whilefieldRuleRootIssueappears 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 atcontent/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::824is the exact line #11034 closed — thehasRoleexample 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.fieldsisz.record;FormFieldSchemaandScreenFieldConfigSchemaarez.array), so afields:map is the object-field layer and afields: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 —
ExpressionProviderbindsdata, notrecord, and nopage.*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: #11407has 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
- added a commit that references this issue
on Sep 17, 2026 - added a commit that references this issue
on Sep 28, 2026
Filed unassigned by the
domain:devxseat while implementing #11034 (sessionsession_015ahemw8RcTgqtxrj15PEZx). Triage on #11034 ruled this gap is its own gate card and explicitly scoped it out of that PR:Recording only. Not claimed, not queued, not graded.
The gap
{/* os:check */}blocks incontent/docs/**are type-checked bypackages/spec/scripts/check-skill-examples.ts(tsc --noEmitover the extracted blocks). A visibility predicate is authored as a bare assignment whose value is typedstring:visibleWhen: "record.status != 'closed' && user.hasRole('admin')"hasRoleis 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.mjscloses exactly this class for field formulas, and its own header states the reasoning this card is a second instance of:It cannot reach
visibleWhenas written, deliberately. Its opt-in is structural and narrow — (A) aField.<anything>({ … expression … })factory call, or (B) an object literal carrying bothtype: 'formula'andexpression. AvisibleWhen: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
visibleWhengate cannot use one scope for every site, because the binding root really does differ by layer. Measured onorigin/mainat 365e334 while fixing #11034:visiblerecord,current_user,user,ctx.user,os.user,app,features,page.*(objectuiapp-shell/src/providers/ExpressionProvider.tsx:59,70)visibleWhenrecord+ the host predicate scope (objectuicore/src/evaluator/optionRules.ts:103)visibleWhenrecord+previousonly — all threeresolveFieldRuleStatecall sites in objectuicomponents/src/renderers/form/form.tsx(1201, 1237, 1935) passundefinedfor thescopeparameterSo 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): avisibleWhen/readonlyWhen/requiredWhenslot whose enclosing structure identifies the layer, validated through@objectstack/formula'svalidateExpressionwith 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
nameconvention in content/docs/** examples — os:check passes becausenameis.optional()#10830 —os:checkcannot enforce the form-sectionnameconvention. Different mechanism: there the key is declared and.optional(), so the block is genuinely well-typed. Here the value's content is unvalidated.check:skill-examples' marked-block extraction is not fence-aware. Extraction, not semantics.visibleWhengate found by census: one authored-node bypass, a second evaluator with an oppositedatabinding, and total silence on fault in production (all land in objectui) #11258 — three gaps in the runtimevisibleWhengate (all landing in objectui). Runtime enforcement, not the docs corpus.PageComponentSchema.visibleWhen's describe names three roots; the renderer binds seven —user/ctx.user/os.user/app/featuresare undocumented on that surface #11256 — the specdescribeforPageComponentSchema.visibleWhennames three roots where the renderer binds more. Independently corroborates the binding table above.Generated by Claude Code