Repository navigation
fix(spec): a page component's visibleWhen names no data root — the shipping renderer mounts none - #23146
Conversation
…shipping renderer mounts none The `visibleWhen` describe and its "Ambient roots" docblock said the renderer "binds `data` to the data-source ADAPTER here". objectui's SchemaRenderer stopped binding `data` on this surface (ruling B on the renderer seam's data scope), so a `data.*` gate faults and the component renders in both polarities. Both faces now name what the renderer mounts (`features`, `os.user`), state that no `data` root is mounted, tie the one declared `data` read to the PLANNED API binding, and keep the `page:tabs` item evaluator's row binding. A new pin holds the claim; the `app`-root pin drops its two `data` survival controls. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
`gen:docs` republishes the corrected `PageComponent.visibleWhen` describe into content/docs/references/ui/page.mdx (both tables that carry it); the changeset declares `@objectstack/spec` patch, Clause-② no. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c8295030a50c522f8bf405713c2d18d01d4670aa && git checkout c8295030a50c522f8bf405713c2d18d01d4670aa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1ca7a805ca810bb9eb1c1f6910401f8c582129db 8f64f1ae3484485d9cfc0d1733456cc575aaa11e && git checkout -B drift-repro 1ca7a805ca810bb9eb1c1f6910401f8c582129db && git merge --no-ff 8f64f1ae3484485d9cfc0d1733456cc575aaa11e
node scripts/docs-audit/affected-docs.mjs --json 1ca7a805ca810bb9eb1c1f6910401f8c582129db
|
Contract reviewServed-tier: PR #23146 (card #23036; body lines ① Derived judgmentsPublished surface touched: the
② Semver level
③ Boundary flagsThe dev report 6137432578 lists
Check-runs on the head, as read. Read at 2026-10-13T03:35Z: 39 check-runs — 28 success, 5 skipped ( Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #23036
Clause-②: no
The
visibleWhendescribe onPageComponentSchemaand the 「Ambient roots」 docblock above it now name only what the shipping renderer mounts. They say that nodataroot is mounted on this surface, as objectstack-ai/objectui#9308 ruling B left it. ⛔ Nodataroot is bound or refused in code, no accept set moves, and there is no renderer change. Text only, plus pins, the regenerated reference page, and a patch changeset.Spelling note: the schema text writes page state as
page.followed by a variable name in angle brackets. That fragment does not survive a GitHub body, so it is writtenpage.VARbelow.What the renderer mounts (the readings the new text states)
All readings are at objectui
origin/mainf228d710. The four evaluator files (SchemaRenderer.tsx,ExpressionProvider.tsx,hooks/useExpression.ts,containers.tsx) are byte-identical at the.objectui-shapin1fb1ced36243(diffof each file at the two commits: identical).packages/react/src/SchemaRenderer.tsx:665-676(configEvaluationScope) builds{ ...predicateScope, current_user: predicateScope.user, record (only when a row is bound), page }. The memo builds this surface's evaluator from it (:1228-1229), and thevisibleWhenleg callsevaluator.evaluateConditionon that evaluator (:1319, the production branch). Nodatakey. The decision is written at:1194: "⛔datais NOT here, and the absence is the decision (objectui#9308, maintainer ruling 2026-09-13 option B)".predicateScopeisusePredicateScope(), fed throughPredicateScopeProvider. In the console its only production feeder is app-shell'sExpressionProvider:git grep PredicateScopeProvideroverpackages/*/srcfinds one non-test provider mount,ExpressionProvider.tsx:306. ItsbuildExpressionScope(ExpressionProvider.tsx:181-190) returns{ current_user, user, ctx: { user }, os: { user }, features }. Sofeaturesandos.userare mounted, anddatais not.page:tabsitem'svisibleWhen. This is a separate evaluator,packages/components/src/renderers/layout/containers.tsx:642-649. It spreads the row's bare fields, spreadspredicateScope, and bindscurrent_user,record: row,data: rowandpage. The old "record ROW" sentence is still true, so it stays.data.*gate does on the component.ExpressionEvaluator.evaluateCondition(packages/core/src/evaluator/ExpressionEvaluator.ts:341onward) answers a faulting predicate with the fail-softtrue, and the SHOW leg negates that into "not hidden". So the component renders whether the predicate is true or false. This matches the card's measurement throughSchemaRendereratd758f2f2(data.total > 0anddata.total == 0both SHOWN).provider: 'api') is open.isElementDataSourceConfig(packages/core/src/data-scope/element-data-source.ts:169) still admits only a plain object with a stringobject. No renderer reads the API binding today.Each changed sentence, FROM → TO
PageComponentSchema.visibleWhen.describe()(published; republished intocontent/docs/references/ui/page.mdx)The sentences before and after these are unchanged ("Visibility predicate (CEL) … only when TRUE.", "Contract-bound roots: …", and the closing
e.g.example).features,os.userand bindsdatato the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only)."TO: "The shipping renderer additionally mounts
featuresandos.user— renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NOdataroot here: adata.*read faults and a faulting predicate renders the component, so such a gate never gates; the page's row isrecord."Backed by:
configEvaluationScope,buildExpressionScopeandevaluateConditionabove.datais declared only for an element whosedataSourceis the PLANNED API binding (provider: 'api'), which reads its bound value asdataonce that binding's renderer ships."Backed by: the
provider: 'api'describe (page.zod.ts:502, unchanged) and objectui#12197 still open.datais surface-dependent: on apage:tabsitemvisibleWhenit is the record ROW instead."TO: "
page:tabsitemvisibleWhenis a different evaluator: it bindsdatato the record ROW."Backed by:
containers.tsx:642-649.The TSDoc docblock above
visibleWhen(read by whoever opens the file)featuresandos.userfrom app-shell'sExpressionProvider, and bindsdata."TO: "… from app-shell's
ExpressionProvider." The rest of the paragraph (no ADR rules those roots on this surface) is unchanged.190fbd01d061and objectuif2732afe,ExpressionProvider.tsx:59,SchemaRenderer.tsx:463-471.TO: pin
1fb1ced36243and objectuif228d710,ExpressionProvider.tsx:181-190(buildExpressionScope:current_user,user,ctx.user,os.user,features, and nothing else), andSchemaRenderer.tsx:665-676(configEvaluationScope, addingrecordwhen the page has a row, andpage).datais surface-dependent. HEREdatais the data-source ADAPTER — what${data.total}in aproperties/props/contentvalue interpolates against. On apage:tabsitem-levelvisibleWhena separate evaluator (containers.tsx:450-457) bindsdatato the record ROW instead. Same key name, two bindings; …"TO: two sections.
dataroot on this surface": ruling B removeddataas a runtime expression root (SchemaRenderer.tsx:1194). A data scope reaches a predicate only through the predicate-scope channel, whose one shipping feeder publishes nodata. So adata.*read faults and the component renders in both polarities. The row isrecord. Then: the onedatathis surface declares is the PLANNED API binding's, read once that binding's renderer ships, and no element has adatato read today.page:tabsitem is a different evaluator":containers.tsx:642-649bindsdatato the row and spreads the row's bare fields. That is a binding this surface does not have.${data.total}interpolation claim is dropped, not reworded.propertiesvalues are evaluated by the sameconfigEvaluationScopeevaluator, sodatadoes not resolve there either.How the two texts agree (dispatch assumption A3)
page.zod.ts:502(unchanged) says the PLANNED API binding "binds the selected value, which the element reads asdatain its expression holes and itsvisibleWhen". The newvisibleWhentext makes both statements true together. Today nodataroot is mounted. Adataread exists only on an element that carries the API binding, and only once that binding's renderer ships.:502did not have to change, so there is no open question. The new pin holds the agreement: it goes red if the API binding's describe loses its[PLANNED — not yet rendered]marker while this text still calls it planned.Verification record
Every reading below is at head
8f64f1ae3(git rev-parse --short HEAD), which isf43d856caplus96b07ccb9plus a merge oforigin/maindfb3ba2b2. The merge touched no file inpackages/spec.Pins. New
packages/spec/src/ui/expression-scope-data-root.pin.test.ts. It checks six things:featuresandos.user, on both faces;datais one of the three true readings (not mounted, PLANNED binding,page:tabs);page:tabsrow reading survives;ElementApiDataSourceSchema's describe holds.DARK controls run the classifier over the replaced sentence and require it to fail. They also prove the splitter keeps dotted tokens together, and that a missing anchor throws. The existing
expression-scope-app-root.pin.test.tsdrops its twodatasurvival controls (toContain('data')on the describe,toContain('bindsdata')on the docblock), and itsappassertions are unchanged.Red first. I committed the fix, wrote BASE
bcebd8409'spage.zod.tsto disk under atrap … EXIT INT TERMrestore, and ran the new pin. On-disk proof: retired-claim grep 1, new-claim grep 0, blob7519cc64against HEAD blob62efbb6f. Result:Tests 5 failed | 6 passed (11). The five red assertions are both mounted-roots sentences (expected [ 'features', 'os.user', 'data' ] to deeply equal [ 'features', 'os.user' ]), both absence assertions, and the PLANNED-binding sentence. Restore was proved by blob62efbb6fequal to the HEAD blob and an emptygit diff HEAD.git status --porcelainwas empty afterwards.Spec suite, both projects.
pnpm --filter @objectstack/spec exec vitest run --project local --project repo --maxWorkers=2reportedTest Files 717 passed (717)andTests 20995 passed | 1 todo (20996), with VERDICT command-exit 0.Generated artifacts.
pnpm --filter @objectstack/spec build, thencheck:generatednamed exactly one stale artifact (content/docs/references/ui/page.mdx, two rows). Aftergen:docs, the re-run printed✓ All 14 generated artifacts are up to date.Gate families. I derived them with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat8f64f1ae3: 109 commands. All exceptpnpm check:type-check-debtwere run, which is NOT MEASURED by this dispatch's order. Each exit was captured before any pipe. Results: 102 exit 0 on the first pass. Six exited 3 withPREREQUISITE NOT METbecause unbuilt packages meant nothing was measured:@objectstack/lintcheck:doc-formula-expressionsandcheck:doc-security-posture, speccheck:skill-examples,check:docs-transcript-drift,check:lean-entry-closure, andcheck:dual-build-cjs-loads. After building the@objectstack/lint...,@objectstack/objectql...and@objectstack/client-react...closures, the first five re-ran exit 0. Then I built the rest of the workspace (turbo run build --filter=!@objectstack/docs, 74 tasks, 35 cached), andcheck:dual-build-cjs-loadsre-ran exit 0:108 published require entry point(s) across 67 package(s) load.--ranreconciliation over the final record (every line carries its exit code):109 derived famil(ies) accounted for — 108 run, 1 NOT-MEASURED (0 DERIVED from a recorded exit 3, 1 claimed). The one claimed ischeck:type-check-debt, by order.pnpm --filter @objectstack/spec typecheckexited 0. Both pin files are intsconfig.test.json's program (tsc --listFilesOnly, counted).Lint. The
os validatereading for dispatch assumption A4 is under Acceptance notes.Not run locally, declared narrowing.
turbo ls --affectednames 82 packages:@objectstack/specand every dependent. This diff changes.describe()and TSDoc text in one spec file and nothing else in any package. No export, type, accept set or wire shape moves (check:api-surfaceandcheck:authorable-surfaceare green).git grepfor the old and new sentences outsidepackages/spec/srcfinds only the regenerated reference page and this PR's changeset. So the spec package's own suite is what this change can move. The other 81 packages' suites are CI's Test Core. Blind spot, stated with that claim:turbo ls --affectedcomes from the package dependency graph, so it cannot see a test that reads another package's source from disk.check:cross-package-test-inputsis green, with every such read declared. The repo-root suites are not on the graph.Changeset.
.changeset/23036-visiblewhen-no-data-root.md:@objectstack/specpatch,Clause-②: no.check-adr-0087-registrationreads it as non-breaking, so no marker is owed.Acceptance notes
data.-rooted page-component predicate isvisibility-root-mislayered(severitywarning), notvisibility-bare-identifier.datais in@objectstack/formula'sSCOPE_ROOTS, so the bare-identifier rule never fires on it. Reading:pnpm --filter @objectstack/lint exec vitest run src/validate-visibility-predicates.test.tsfiltered to the page-component and layer cases gave48 passed | 148 skipped. That covers "a PAGE COMPONENT predicate is still read through the page-sidevisibilitykey" and "resolves predicates stored as{ dialect, source }envelopes", both[VISIBILITY_ROOT_MISLAYERED]ondata.x. The rule's runtime message already says such a predicate "never matches and the element always renders", which agrees with the new describe. No lint change is owed.packages/spec/src/ui/component.zod.ts:994-995, in the TSDoc of thepage:tabsitemvisibleWhen, says "datais the record ROW here, where the component-node evaluator binds it to the data-source ADAPTER". The second half is the claim this PR retires. The same docblock citescontainers.tsx:450-457at pin190fbd01d061; the lines are642-649at1fb1ced36243. The published describe at:1017("bindsdatato the record ROW (not the data-source adapter)") is still true. The claim's file surface namespage.zod.tsonly, so I stopped at that fence. Suggested text for whoever carries it: "datais the record ROW here; the component-node evaluator mounts nodataroot at all (seePageComponentSchema.visibleWhen)". Its pin would be one more TSDoc face inexpression-scope-data-root.pin.test.ts.@objectstack/lint.packages/lint/src/validate-visibility-predicates.ts:343records a measurement "a page component additionallypageand the data-source adapterdata". The argument it supports still holds, becausedatais inSCOPE_ROOTSeither way. Comment only, with no author-visible reach. Carrier: none.SchemaRendererbinds nodataof its own. So the planneddataread in an element'svisibleWhenhas one existing way in, the predicate-scope channel, which ruling B left un-shadowed for exactly this. This is an observation for that card's carrier, not a decision made here.Generated by Claude Code