Repository navigation
Description
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:checkgap (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-examplesrunstsc --noEmitover blocks markedos: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
- [finding] Nothing enforces the form-section
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. - [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 —
check:skill-examples' marked-block extraction is not fence-aware. Extraction, not semantics. - [finding] Three gaps in the
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. - finding:
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
Activity
claude commentedon Aug 24, 2026
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 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
os-steve commentedon Aug 24, 2026
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.⛔ 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:
visiblerecord,current_user,user,ctx.user,os.user,app,features,page.*visibleWhenrecord+ host predicate scopevisibleWhenrecord+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.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.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.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.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
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
os-steve commentedon Aug 24, 2026
{ "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
os-steve commentedon Aug 24, 2026
Landed — PR #11652, verified by content on
origin/mainCard 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: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.: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 —⚠️ That measurement covers the provider that builds the scope, not every page-layer consumer of it — one corroborating data point, not a finished census.
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.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
fix(skills): state the ADR-0053 upper-bound rule in filters.md by ref…
pm: a seat's claimed model tier has a mechanical source and a check —…