Skip to content

feat(lint): judge docs-corpus *When predicates as CEL, where the enclosing structure names the layer - #11652

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11407-visiblewhen-cel-fence
Aug 24, 2026
Merged

feat(lint): judge docs-corpus *When predicates as CEL, where the enclosing structure names the layer#11652
os-steve merged 1 commit into
mainfrom
claude/issue-11407-visiblewhen-cel-fence

Conversation

@os-steve

@os-steveos-steve commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11407

os:check runs tsc --noEmit over marked blocks, and every CEL string is the same type as every other CEL string — so visibleWhen: "record.status != 'closed' && user.hasRole('admin')" type-checked perfectly. hasRole is a CEL function that exists nowhere, so the predicate faults at runtime, and a field-level visibleWhen fault is fail-open: resolveFieldRuleState evaluates visibility with fallback: true, so the element the author wrote the predicate to hide is shown to everyone who copies the page.

One gate, not two

Extended packages/lint/scripts/check-doc-formula-expressions.mjs with a third scan surface rather than minting a second opinion about one contract (Prime Directive #12). The verdict is imported whole, in the order and the words the metadata walk already uses for this slot:

  1. @objectstack/formula's validateExpression('predicate', src, { scope: 'record' }) — syntax, the unknown-function/overload catch, the Formula guardrail: cel-js arithmetic silently returns null (double × int + bare identifiers) #1928 bare-reference rule;
  2. fieldRuleRootIssue(slot, src) — the closed-root rule.

(2) did not exist as a callable rule. It was a closure, checkFieldRuleRoot, inside validateStackExpressions — correct while it had one caller, and exactly how a second caller comes to own a dialect of a rule instead of the rule. It is hoisted to an exported fieldRuleRootIssue (with FIELD_RULE_BOUND_ROOTS) and the metadata walk now calls it. Behaviour is unchanged: packages/lint's 2271 tests pass untouched. That relocation is most of the line count in validate-expressions.ts.

The layer is decided first, and an undecidable one is skipped and printed

visibleWhen is one key spelling several unrelated contracts. Admission is structural and schema-backed, never keyed on the key:

  • CField.*({ … visibleWhen … }), a field factory call (the same arm-A shape surface 1 uses).
  • D — a raw field def carrying a type: string-literal discriminator that sits as a value in an object-literal fields:map.

D's map-versus-array test is the load-bearing half, read off the schemas rather than from taste: ObjectSchema.fields is z.record(name, FieldSchema) (object.zod.ts:1892), while FormFieldSchema (view.zod.ts:2058) and ScreenFieldConfigSchema (builtin-node-config.zod.ts:447) are both z.array(…). So a fields: map is the object-field layer and nothing else, and a fields: array is exactly the case that cannot be told apart. Both arms take the slot as a direct property, so a visibleWhen one level down in options: stays out — per-option genuinely binds current_user, and admitting it would false-red legal metadata.

The skip list is printed and counted on every run, including green ones, and the self-test pins that the green path still prints it (not merely its count). A gate that skips in silence is the false-green this surface exists to prevent, one level up.

Live corpus, measured — the accounting closes

count
text-level *When: occurrences in TS/TSX fences23
— the ADR quoting field.zod.ts's schema, not predicates3
admitted and judged13
skipped and listed7

The admitted population is not zero, so the gate is live rather than a guard against tomorrow. Two of the seven skips are the false reds a key-keyed gate would have produced, sitting in the corpus today: content/docs/automation/flows.mdx:320 teaches the correct barevisibleWhen: 'createOpportunity == true' (a flow screen flattens its own field names), and content/docs/ui/pages.mdx:165 teaches the correct 'sales_manager' in current_user.positions on a page component.

A silent blind spot found and closed on the way. An AST-only walk saw 17 of the 20 real sites: a bare visibleWhen: "…" line at statement position is a labelled statement, not a property, so three genuine predicate examples in layout-dsl.mdx produced zero sites and printed nothing. A text-level tripwire — matching only *When: followed by a quoted/tagged value, so the ADR's visibleWhen: ExpressionInputSchema.optional() is not fabricated into a site — reconciles the two counts, ranked lowest so a real parse always outranks it.

Non-vacuity, both directions, mutation proven on disk

Three legs on content/docs/data-modeling/fields.mdx, each under trap restore EXIT INT TERM, each proving the mutation landed (git hash-object change plus injected/replaced grep -c counts) before reading any verdict, and each restoring byte-identically.

legmutationgate
Auser.hasRole('admin') on a Field.selectvisibleWhenexit 1found no matching overload for 'dyn.hasRole(string)'
Bcurrent_user.profile == 'admin' on the same field-level slotexit 1current_user is unbound here
Cthe same text as B, one level down on a per-option visibleWhenexit 0 — skip count 7 → 8, listed, not judged

C is the pair that matters: identical predicate text, red where the field level does not bind its root and unjudged where the per-option layer genuinely does. The layer decides, not the key. Original blob 3fcc2c68… restored after every leg; git status --porcelain clean.

Zone 2 — the card's binding table re-measured, and where it had drifted

The table was taken at objectui 365e334; re-measured on objectui origin/main2aff580:

  • Form/field row — holds, and is now stronger.evalFieldPredicate binds { record, previous, ...(scope ? { extra: scope } : {}) }. The card said "all three resolveFieldRuleState call sites pass undefined for scope". There are now four, all passing undefined: packages/components/src/renderers/form/form.tsx at 1201, 1237 and 1945 (the card said 1935), plus apps/console/src/components/FormPage.tsx:697 (resolveRowState), which the card did not name.
  • Per-option row — holds.optionRules.tsevalFieldPredicate(o.visibleWhen, record, true, undefined, scope, …): record plus the host scope, previous never bound.
  • Page/nav row — the card's cell is imprecise, and I am not reconciling it here. Today's ExpressionProvider builds { current_user, user, ctx: { user }, os: { user }, app, data, features } — that is data, not record, and no page.* at that provider. ⚠️finding: PageComponentSchema.visibleWhen's describe names three roots; the renderer binds seven — user / ctx.user / os.user / app / features are undocumented on that surface #11256 is in flight on this exact ground from the spec side. Per dispatch I am reporting the disagreement rather than reconciling it: nothing in this PR depends on that row — no admitted site is a page component, and the page layer is skipped by construction.

The card's motivating example was already fixed by #11034, as the dispatch expected; the class is what this closes.

Verification

Gate union derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths — the script reads its own changeset), re-run at final commit b160114eb on a clean tree, exit codes captured before any pipe. All 17 families exit 0, including the card's own pnpm --filter @objectstack/lint run check:doc-formula-expressions (self-test 48 cases, up from 30).

pnpm --filter @objectstack/lint test — 81 files, 2271 passed. typecheck clean. Repo-wide eslint . --no-inline-config ran in full (no narrowing claimed): 5036 files, 0 errors, 0 warnings.

Out of scope, filed not fixed


Generated by Claude Code

…yer is determinable
`os:check` type-checks a CEL string as `string`, so a `visibleWhen` calling a
function that exists nowhere (`user.hasRole('admin')`) type-checked perfectly —
and a field-level `visibleWhen` fault is fail-OPEN, so the copied example shows
the element to everyone.
Extends `check-doc-formula-expressions` with a third scan surface rather than
minting a second opinion about one contract. The verdict is imported whole:
`validateExpression` (syntax, unknown-function, bare reference) plus the
newly-extracted `fieldRuleRootIssue` (the closed-root rule) — the same two the
metadata walk applies to the same slot.
Admission is structural and schema-backed, never keyed on the key: a `Field.*()`
factory call, or a raw field def carrying `type:` inside an object-literal
`fields:` MAP (`ObjectSchema.fields` is `z.record`; `FormFieldSchema` and
`ScreenFieldConfigSchema` are `z.array`). A site whose layer is not determinable
is skipped, and the skip list is printed and counted on every run — including
green ones — because a gate that skips in silence is the false-green this
surface exists to prevent, one level up.
`fieldRuleRootIssue` / `FIELD_RULE_BOUND_ROOTS` were a closure inside
`validateStackExpressions`; hoisted and exported so both callers stand on the
rule rather than a dialect of it. Behaviour unchanged — 2271 lint tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 11 documentable anchor(s).

29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d7443402bf3b932043180abbb3230f686fcd8125.

7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d7443402bf3b932043180abbb3230f686fcd8125packageMentionDocs.

Which tree this was computed on

This run read content/docs from 56a9e3581c08f3e8bfc5367b4f785519b32b2e99 — the merge of head b160114eb8d680d31016ad4f0b37472d7c1138a0 into base d7443402bf3b932043180abbb3230f686fcd8125, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 56a9e3581c08f3e8bfc5367b4f785519b32b2e99 && git checkout 56a9e3581c08f3e8bfc5367b4f785519b32b2e99
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d7443402bf3b932043180abbb3230f686fcd8125 b160114eb8d680d31016ad4f0b37472d7c1138a0 && git checkout -B drift-repro d7443402bf3b932043180abbb3230f686fcd8125 && git merge --no-ff b160114eb8d680d31016ad4f0b37472d7c1138a0
node scripts/docs-audit/affected-docs.mjs --json d7443402bf3b932043180abbb3230f686fcd8125

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d7443402bf3b932043180abbb3230f686fcd8125 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tooling labels Aug 24, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review, with one reconciliation to close and a ruling on your open question.

Verified by content

packages/lint/** main branch
fieldRuleRootIssue 0 -> 8
checkFieldRuleRoot 3 = 3 (closure kept; now delegates)
CTRL validateExpression 35 -> 39 (non-zero both sides)
branch validate-expressions.ts:660 export function fieldRuleRootIssue(
branch validate-expressions.ts:853 const issue = fieldRuleRootIssue(slot, source);
branch index.ts:30 export { validateStackExpressions, fieldRuleRootIssue, FIELD_RULE_BOUND_ROOTS }

checkFieldRuleRoot staying at 3 while fieldRuleRootIssue appears is the shape that matters: the closure was not duplicated into a second implementation, it now calls the hoisted one. One opinion, two consumers — which is the whole reason this was worth doing rather than reimplementing the root rule in the .mjs gate.

⭐ The labelled-statement blind spot is the best thing in this PR

Confirmed verbatim 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"

All three sit at statement position inside a ```typescript fence with no enclosing object literal — so they parse as labelled statements (label visibleWhen, body a string expression), not properties. An AST-only walk sees no Property node and reports nothing, honestly and silently, on three real authored predicates.

That is a blind spot which looks exactly like a clean corpus. Finding it required noticing that 17 sites did not equal 20 and refusing to let the difference go, and the reconciliation you built — a text-level tripwire narrowed so the ADR's visibleWhen: ExpressionInputSchema.optional() is not fabricated into a site — is the right fix, because it makes the two counts have to agree rather than trusting either alone.

⚠️ Worth stating out loud, because it decides how the gate should be maintained: :824 is the exact line #11034 closed this shift (user.hasRole('admin'), a function that exists nowhere in spec or core, fail-softing to visible). Your LEG A ablation re-injects that same defect on a field-level slot and the gate reds on it. So this gate would have caught #11034 at authoring time — and, before your tripwire, its AST arm would have been blind to that very line. The blind spot and the motivating defect were the same site.

One reconciliation, please close it rather than leave it

I counted text-level visibleWhen|readonlyWhen|requiredWhen: occurrences across the gate's own ROOTS = ['.claude','docs','skills','content'], minus the declared SKIP_PATHS:

.claude/** 0 docs/** 3 skills/** 4 content/** 17 TOTAL 24
skipped-path hits: 0

You report 23. The single difference I can locate is:

content/docs/releases/v17.mdx:414
+ fields: { due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' } }

It is followed by a quoted value, so it should pass your tripwire — but it sits inside a diff fence, not a TS block, which would explain the gate not seeing it. That is my hypothesis, not a measurement — I did not read your block-selection code. Please confirm which it is: if the diff fence explains it, say so in the PR body so the two numbers reconcile on the record; if it does not, then the tripwire is off by one somewhere and I would rather know now. Either way the load-bearing claim is untouched — 13 admitted is not zero however the total resolves.

LEG C is the ablation that proves the card

Same predicate text (current_user.profile == 'admin'), 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 — that is exactly the discrimination this card exists for, and it could not have been demonstrated by a red-only ablation. Marker counts plus sha on every leg, byte-identical restores, git status clean.

Also right: the skip list prints and is counted on green runs too, pinned by the self-test. A blind spot you can only see when the gate is already failing is not a declared boundary.

Ruling on your open question — A, and do not file B

Route the objectui measurement to #11256 as evidence. Your reasoning is correct and it is the reason that decides it: #11256 is already standing on this exact ground, and a second card would produce the two-opinions shape this card was filed to refuse.

Two conditions on how you hand it over:

  1. Carry your scope limit verbatim into that comment. You measured ExpressionProvider — the site that builds the scope — not every page-layer consumer of it. finding: PageComponentSchema.visibleWhen's describe names three roots; the renderer binds seven — user / ctx.user / os.user / app / features are undocumented on that surface #11256's owner must inherit "one corroborating data point" and not mistake it for a finished census. An over-claimed measurement handed to another lane is worse than none, because they will stop looking.
  2. Do not open the objectui-side card now. If finding: PageComponentSchema.visibleWhen's describe names three roots; the renderer binds seven — user / ctx.user / os.user / app / features are undocumented on that surface #11256 closes having settled only the spec-side description without an objectui census, that residue is a follow-on — filed then, against a known gap, not now against a speculative one.

Nothing in this PR depends on the answer: no admitted site is a page component, and the page layer is skipped by construction. Recording the card's row as IMPRECISE rather than reconciling it unilaterally was the right call under the dispatch.

Out of scope

#11651 — four of the seven skips are skipped only because of how the fragment is written, and all four are correct documentation. Correctly not fixed here, and the selector correctly not widened. "Widen the selector until it matches" is how a gate acquires a population it was never verified against.

My own probes, for the record

Two of mine were broken before they were right: I first read layout-dsl.mdx at content/docs/spec/, a path that does not exist, and got empty output that I could have read as "no such lines"; and an earlier git grep | sed pipeline masked grep's exit code so its zero-hit branch could never fire. Both were caught by controls, not by inspection. Mentioned because your corpus reconciliation is the same discipline pointed at your own tool, and it is the reason I trust the 13.

Flipping ready; arming once the last checks clear.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 24, 2026 10:43
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Reconciliation closed — no action needed from you. My 24-vs-23 question is answered by your own #11651, which states the qualifier my count was missing: "text-level *When: occurrences in TS/TSX fences".

My 24th is content/docs/releases/v17.mdx:414, which sits in a ```diff fence:

+ fields: { due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' } }

A quoted value, so it passes the tripwire's value test, but it is not a TS/TSX block, so it is outside the scanned population by construction. 24 raw − 1 non-TS fence = 23, and 3 ADR + 13 admitted + 7 skipped = 23 closes exactly. The hypothesis I flagged as unconfirmed is confirmed, and by your artifact rather than by my guess.

Worth one line in the PR body anyway, since the next person to count will hit the same 24: say the corpus is TS/TSX fences, not raw text. That is the difference between a reader reproducing your number and a reader thinking the tripwire is off by one.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xltooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-steve@claude