Skip to content

fix(console,plugin-form): bind the host predicate scope on the two unbound authored-predicate evaluators - #6261

Merged
yinlianghui merged 3 commits into
mainfrom
claude/issue-6110-predicate-scope-unbound-evaluators
Aug 25, 2026
Merged

fix(console,plugin-form): bind the host predicate scope on the two unbound authored-predicate evaluators#6261
yinlianghui merged 3 commits into
mainfrom
claude/issue-6110-predicate-scope-unbound-evaluators

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6110

Gate union run on 88a49aeca (the final commit on this branch).

What was broken

objectui#6010 bound the host predicateScope on the five authored-predicate
call sites in packages/components/src/renderers/form/form.tsx. Two other
authored-predicate evaluators still passed undefined for that argument, so the
defect #6010 closed on one chain was still live on them: apps/console's form
renderer (both routes) and WizardForm's submit-time required re-check.

visibleWhen is evaluated with fallback: true, so an unbound root faults
OPEN — the field or section a current_user test was meant to hide was
shown to everyone. requiredWhen faults the other way (fallback: false), so a
current_user requiredWhen silently stopped applying.

⚠️ The card's enumeration was incomplete — SIX repair sites, not four

The card named four sites across two files. Tracing the scope argument from
where it is available to where it is consumed found two more, and one of them
would have made the whole console half ship inert.

#siteon the card?
1FormPage.tsxisFieldVisible — view-level field visibleWhen✅ (filed as :610, now :611)
2FormPage.tsxisSectionVisible — section visibleWhen✅ (filed as :651, now :653)
3FormPage.tsxresolveRowStateresolveFieldRuleState — the OBJECT-levelrules.{visibleWhen,readonlyWhen,requiredWhen}missed
4InternalFormRoute.tsx — nothing published a scope at allmissed, and load-bearing
5WizardForm.tsxresolveFieldRuleState✅ (:440, now :456)
6WizardForm.tsxevalFieldPredicate (visibleOn)✅ (:471, now :492)

Site 4 is the one that matters.usePredicateScope() returns {} unless an
ExpressionProvider is mounted above, and this repo mounts one in exactly two
places — AppContent (for the /apps/:appName/* subtree) and app-shell's
RecordFormPage. Neither is above /forms/:name, which App.tsx mounts
through InternalFormRouteDefaultHomeLayout. Binding the evaluator call
sites alone would have read {} forever: green pins, unchanged product. The
route now mounts the provider over buildExpressionUser, the same
normalisation AppContent uses — imported rather than re-derived, because it is
what supplies positions: [], and an absent key makes
'x' in current_user.positions an unbound-key FAULT (fail-open) instead of
resolving false. @object-ui/app-shell exports it for that reason.

Line numbers re-derived on this branch; the card was measured @ a100f77.

⛔ The fork clause on the anonymous route — answered by STRUCTURE, no new surface

The dispatch's fork clause: if the routes can be told apart without inventing
new contract surface, that is the fix; if not, stop and report. It reduces to
the first branch
, so it is implemented rather than escalated.

The two routes share one call site but not one mount. /forms/:name renders
inside InternalFormRoute, which has an authenticated session and now publishes
it. /f/:slug is mounted bare in App.tsx, deliberately outside
ProtectedRoute so an anonymous visitor can submit it — so no provider sits
above it and usePredicateScope() returns {}. That is not a gap left
unfilled: an anonymous form has no principal, and binding an empty scope is
exactly that statement. Nothing new is declared, no key is invented, no mode
flag is read at the evaluator. publicRouteHasNoPrincipal pins it as behaviour:
on the public route the same authored text still faults and still fails open,
unchanged by this PR.

features is likewise {} on the internal route rather than fetched.
ExpressionProvider already documents {} as the pre-load state whose
predicates default to visible; wiring a deployment-config fetch into this route
would be a different card.

PM mechanism assumptions — verified, not inherited

  1. All four filed line numbers — ✅ re-derived; all four still resolve to the
    named call, with the drift noted in the table above.
  2. Form section/field visibleWhen binds no current_user — position-gated visibility works on pages and per-option rules, but silently fail-opens on form fields #6010 really bound the renderer — verified by RUNNING, not reading.
    vitest run packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx
    Test Files 1 passed (1) · Tests 15 passed (15). That file is the oracle
    for what "bound" looks like, and its host-scope shape is transcribed verbatim
    into both new pins.
  3. Fail direction is OPEN — ✅ confirmed at
    packages/core/src/evaluator/fieldRules.ts:157: fallback is returned for
    every not-ok verdict, and both visibleWhen readers pass true. Measured,
    not read: the pre-fix run failed with the gated field on screen.
  4. The wizard docstring still says it — ✅ verbatim, at WizardForm.tsx
    missingRequiredByStep: "the same one the form renderer and the server's
    rule-validator use, so a conditionally required/hidden field gets the same
    verdict from all three rather than a second, divergent dialect."
    Since Form section/field visibleWhen binds no current_user — position-gated visibility works on pages and per-option rules, but silently fail-opens on form fields #6010
    it was the divergent one.
  5. form.tsx's legacy condition synthesis (filed :1955, now :1952)
    — ✅ confirmed deliberately undefined, with the reason in its own comment
    (the predicate is synthesised from {field, equals} and can only ever name
    record.). Untouched.

⚠️ Sibling PR overlap — none

#6239 (objectui#6111) was still open and unlanded at branch time. Its file list
is ObjectForm/ModalForm/DrawerForm/SplitForm + packages/types + its own pin
and changeset — WizardForm.tsx is not in it, exactly as its report said.
This branch is cut from origin/main @ cbc883960, which is also #6239's base.
No edit was made across it, and no apps/console file this PR touches is in the
i18n census overlap that was flagged (FormPage.tsx is touched here only in the
three evaluator calls and one hook declaration).

Tests — every case asserts HIDDEN / BLOCKED, never merely SHOWN

Because the predicate fails open, a field being shown is the outcome of
predicate-true, scope-unbound, and predicate-faulted. An "is shown" assertion
distinguishes none of them and is green on unfixed code. Both files therefore
author a current_user predicate that is false for the bound principal and
assert the field is absent — and, in the other direction, a requiredWhen
that is true for the principal and assert the requirement applies, which
no fail-open accident can reach.

  • apps/console/src/components/FormPage.predicateScope.test.tsx — 8 tests
  • packages/plugin-form/src/wizardPredicateScope.test.tsx — 5 tests

Measured before the fix: 5 failed | 3 passed and 3 failed | 2 passed,
each in the predicted direction (fields on screen; expected 'Notes' to contain '*'; create never called; the wizard never returning to the owner step).

Per-site ablation — direction predicted BEFORE each run, all six confirmed

Every leg mutated one site, proved the mutation on disk (anchor uniqueness
asserted inside the mutator — it aborts rather than hitting the wrong arm;
injected marker grepped to 1, removed text grepped to 0; landing site and
git diff --stat printed), restored under trap … EXIT INT TERM, and ended
with git diff HEAD --statempty.

legablated sitepredictedmeasured
AInternalFormRoute provider mount (site 4)only hop1SessionPrincipal red1 failed | 7 passed
BresolveRowState scope (site 3)both OBJECT-level rows red2 failed | 6 passed
CWizardFormresolveFieldRuleState (site 5)object-level visibleWhen + requiredWhen red2 failed | 3 passed
DWizardFormvisibleOn (site 6)only the VIEW-level row red1 failed | 4 passed
EisFieldVisible scope (site 1)only the view-level field row red1 failed | 7 passed
FisSectionVisible scope (site 2)section row andhop1SessionPrincipal red2 failed | 6 passed

Leg A is the measurement that matters: it is the only one that separates a fix
which binds the call sites from a fix that also publishes a scope, and it
shows the other seven console cases staying green while the real route is
unbound — i.e. exactly the inert shipment a card-faithful four-site fix would
have produced.

Leg F red-ing hop1SessionPrincipal is expected and not a leak: that case's
predicate is authored on a section, so it must depend on site 2.

No rebuild was needed for these legs, and that is a property of the setup, not
an omission.
vitest.config.mts aliases every @object-ui/* specifier to the
package's src/, so a mutation to a source file reaches the running code
directly. The six reds are themselves the proof it did.

⚠️ Which assertions would still pass on a revert

Stated plainly, as asked.

Console pin (8): the three controls pass on a full revert —
ALLOWED (a true predicate shows the section, which is also what unfixed code
does), FAULTED (an unbound root fails open either way, and exists so the
DENIED rows mean "evaluated and false" rather than "could not be evaluated"),
and publicRouteHasNoPrincipal (the anonymous route is unchanged by this PR by
design, so this row is green in both worlds — it pins the fork answer, it does
not test the fix). 5 of 8 would go red: the three DENIED rows, the
requiredWhen row, and hop1SessionPrincipal.

Wizard pin (5): the two controls pass on a full revert — ALLOWED and
FAULTED, both of which block the submit before and after. 3 of 5 would go
red
: the two fail-open rows and the fail-closed requiredWhen row.

8 of 13 assertions are the deliverable; 5 are controls that are green either
way
, and every one of the 5 is named above rather than left to be discovered.

Also updated: apps/console/src/__tests__/internalFormShell.test.tsx's
whole-module @object-ui/app-shell mock, which now declares the two symbols
InternalFormRoute consumes. Stubbed rather than made real, deliberately — that
file pins #4109's shell nesting and authors no predicate; the binding is
pinned by hop1SessionPrincipal, which leg A shows goes red the moment the
mount is removed.

Behaviour change — in the changeset, in words

.changeset/6110-predicate-scope-unbound-evaluators.md, minor (never
major). It states that previously-inert current_user predicates will start
hiding fields and sections and start holding submits; that fail-open is why
nobody noticed; that the wizard half is a fix in the submitter's favour (it
stops demanding a field the wizard itself hid); that the public /f/:slug route
is deliberately unchanged; and it tells authors to audit their current_user
predicates before upgrading.

Verification

gateresult
predicate-scope-parity-6010.test.tsx (assumption 2, by RUNNING)Tests 15 passed (15)
FormPage.predicateScope.test.tsx (new)✅ 8 passed — was 5 failed | 3 passed pre-fix
wizardPredicateScope.test.tsx (new)✅ 5 passed — was 3 failed | 2 passed pre-fix
vitest run apps/console/Test Files 77 passed (77) · Tests 877 passed (877)
vitest run packages/plugin-form/Test Files 63 passed (63) · Tests 622 passed (622)
vitest run packages/app-shell/src/{providers,console}/Test Files 79 passed (79) · Tests 536 passed (536)
type-check @object-ui/console (tsc --noEmit && tsc -b … --force)EXIT=0, 0 errors
type-check @object-ui/plugin-form (tsc --noEmit ×2 projects)EXIT=0
type-check @object-ui/app-shell (tsc --noEmit ×2 projects)EXIT=0
turbo run lint --filter=console --filter=plugin-form --filter=app-shell --forceTasks: 4 successful, 4 total, Cached: 0 cached
check-changeset-no-major.mjsNo changeset declares a major bump.
check-changeset-presence.mjs7 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed.mjsAll workspace packages are in the changeset fixed group.
check-control-bytes.mjsOK (scanned 5154 tracked text file(s))
check-vi-mock-specifiers.mjsOK (… 429 carry a mock …)
check-lint-coverage.mjs46/46 packages linted, 0 with outstanding errors
check-type-check-coverage.mjs45/46 via type-check, 0 errors outstanding
check:self-importNo package names itself inside its own src/.
check:phantom-depsEvery in-scope import is declared by the package that publishes it.
check:published-distNo published package's build output carries tooling material. (after a 132s build)

Gate set derived by enumerating each CI job's own step list (ci.yml,
lint.yml, changeset-guard.yml, changeset-presence.yml, control-bytes.yml,
vi-mock-specifiers.yml), not from top-level script names. Exit codes captured
before any pipe.

Two declared narrowings, and one measured trap

  1. vitest run packages/app-shell/ was killed at the container's foreground
    cap under contention from sibling agents. Replaced by the providers/ +
    console/ subtrees — which is where this PR's only app-shell change lives
    (one re-export in index.ts, pointing at console/AppContent.ts) — plus
    tsc --noEmit over the whole package, which is the check that actually
    validates a re-export. CI runs the full farm exactly once regardless.
  2. pnpm type-check / pnpm lint repo-wide are turbo run … over all 46
    packages — repo-scale runs CI owns. The per-package invocations above are the
    same compiler and the same eslint . unit CI runs for the three changed
    packages, so for those packages this is the complete CI unit, not a sample.

⚠️tsc resolves workspace deps through dist, not source. The first
console type-check reported 15 errors that were purely unbuilt sibling packages
(plugin-gantt/map/markdown/timeline/tree) — and it also caught one real
error in this PR's own test file. Recording it because reading that run as "my
change broke the console" (or as "all noise") would both have been wrong; the
closure was built and it re-ran at EXIT=0, 0 errors. vitest is the opposite:
vitest.config.mts aliases every @object-ui/* to src/, which is why the
ablation legs needed no rebuild.

⛔ Left as draft — not marked ready, not enqueued, no auto-merge. The PM lands it.

Generated by Claude Code

…aluators (#6110)
Red before the fix: 5/8 in the console pin and 3/5 in the wizard pin, in both
fallback directions.
…e form routes and the wizard submit gate (#6110)
Adds the changeset, the fixture repair for the whole-module app-shell mock, and
drops an unused test import.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.4 KB3266.6 KB
Main entry chunk (gzip)154.1 KB350 KB
Entry fileindex-B26ShbCi.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.96KB4.16KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.63KB114.68KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.32KB42.81KB
plugin-detail (index.js)244.13KB61.93KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.42KB30.80KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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

Projects

None yet

2 participants

@yinlianghui@claude