Skip to content

fix(app-shell): a record-scoped ConditionBuilder advertises the roots its host binds (objectui#9645) - #9854

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-9645-condition-builder-roots-override
Sep 18, 2026
Merged

os-sales merged 2 commits into
mainfrom
claude/issue-9645-condition-builder-roots-override

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #9645

Clause-②: no

What this changes

ConditionBuilder's raw CEL editor forwarded scope to CelPredicateField and nothing else, so its autocomplete offered whatever introspectScope advertises for that scope — every root ANY predicate site may see. objectui#8167 had already made the editor LINT at record on this mount; the suggestions never followed.

A mount that declares scope="record" now advertises RECORD_CONDITION_ROOTS (record, previous). A mount that binds more declares it through the new roots prop — the shape ObjectFieldInspector (FIELD_RULE_ROOTS, FORMULA_ROOTS) and ConditionalFormattingEditor (ROW_PREDICATE_ROOTS) already use for their own surfaces. A mount that declares no scope forwards undefined and is unchanged.

Suggestions only. roots feeds introspectCelScope, which builds the autocomplete catalog; the lint hint is built without it. So the accept set is untouched: every spelling that linted clean before still lints clean, nothing the server does moves, and HookConditionError stays exactly as loud as objectstack#4775 made it.

Both halves re-measured on this base, not taken from the card

reading instrument answer
what the editor advertised introspectScope('record').roots, installed @objectstack/formula 17.4.0 record, previous, input, os, current_user, user, vars
what the hook server binds objectstack packages/objectql/src/hook-wrappers.ts, at 625db0e ExpressionEngine.evaluate(expr, { record: record ?? {}, previous }), then a thrown HookConditionError two lines later

Both match the card: seven advertised, two bound. The defect is the size the card reported.

The conflation this deliberately did NOT make

ConditionBuilder already carried a roots concept — REFERENCE_ROOTS, documented as the roots a value typed into the VALUE BOX may plainly reference rather than be quoted as literal text. It is a different population and the two do not coincide, which is measurable rather than a matter of taste: REFERENCE_ROOTS carries org and parent, and the engine's record scope advertises neither. Reusing it as the advertised list would have re-introduced the objectui#8155 app failure — advertising a root this editor's own linter refuses. Measured at this base:

org.id == 'o1'   -> ok=false  "bare reference `org` ... Write `record.org`."
record.status == 'done'  -> ok=true

The two lists stay separate, each with its own docblock saying which question it answers, and a case in the new suite reddens if anyone merges them.

Why one declared default plus a prop, and not a single hardcoded list

ConditionalFormattingEditor could hardcode ROW_PREDICATE_ROOTS because it IS the surface. ConditionBuilder is not: six mounts reach it, and their hosts bind different sets.

  • the hook condition and the validation rule are evaluated on the SERVER, which binds record and previous;
  • an action's visible / disabled is evaluated in the browser, where useCondition merges the row over app-shell's ambient predicate scope — buildExpressionScope publishes current_user, user, ctx, os, features.

Nothing a mount passes distinguishes the two, so the component cannot derive per-mount truth; declaring it is the mount's job, which is what the roots prop is for. The default is therefore the set every host binds, and the asymmetry decides the direction: over-advertising on a server-evaluated mount costs the author their write, while under-advertising costs a client mount an OFFER and never a spelling, because the accept set is untouched and the offer is restored by one declared prop. That cost is real and is listed under Acceptance notes rather than left to be discovered.

Evidence

Red before, green after

The pin fails with the forwarding removed and passes with it, run from the committed state, mutation and restore both proven on disk by ablation-replace:

mutate   roots={roots ?? (scope === 'record' ? RECORD_CONDITION_ROOTS : undefined)}  ->  roots={roots}
         anchor 1 -> 0, blob 07a8aaa7b238 -> 0740055bd265   (on-disk, not an exit code)
red      vitest ConditionBuilder.mountRoots.test.tsx  -> exit 1
         5 failed: does not offer `os` / `current_user` / `user` / `vars` / `input` in a hook condition
restore  blob back to 07a8aaa7b238 == HEAD blob, `git diff HEAD` empty
green    same command -> exit 0, 11 passed

The direction is worth stating precisely: only the five DOM absence cases flip. The derived pins and the two controls stay green under the ablation, because they read the advertised list and the engine rather than the forwarding — they fence a different drift and would redden on a different edit.

Gates

All exit codes captured before any pipe. Everything except the full suite re-ran on the merged head f65380b3; the full app-shell suite ran at 3b811573, and the merge that followed it touched plugin-form only.

command exit
pnpm exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.mountRoots.test.tsx 0 — 11 passed
pnpm exec vitest run over the 30 test files naming ConditionBuilder / ConditionWidget / CelPredicateField 0 — 335 passed
pnpm exec vitest run packages/app-shell/ 0 — 718 files, 7062 passed, 1 skipped
pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build 0
pnpm --filter @object-ui/app-shell type-check 0 (after the closure build; its script runs tsc --noEmit AND tsc -p tsconfig.test.json, so the new test file is type-checked too)
pnpm exec eslint on the two changed files, --format json 0
node scripts/check-changeset-presence.mjs 0
node scripts/check-changeset-no-major.mjs · check-changeset-fixed.mjs · check-changeset-claims.mjs 0 · 0 · 0
pnpm check:new-line-citations 0 — 0 new citations
pnpm check:control-bytes 0
pnpm check:test-path-roots 0

The lint narrowing, declared. The repo-wide population is pnpm lint (turbo run lint), which is CI's run and was NOT taken here. Locally eslint ran over exactly the 2 files this diff touches (count read from the --format json output, not asserted). That narrowing excludes nothing: eslint.config.js declares no parserOptions.project and no projectService, so linting is not type-aware and this diff cannot move the verdict on any file it does not contain.

ConditionBuilder.tsx ends with 0 errors and 3 warnings. One (react-hooks/exhaustive-deps) is at this base already. The two new ones are react-refresh/only-export-components, raised by exporting a constant from a component file — byte-for-byte the pair ConditionalFormattingEditor.tsx already carries for ROW_PREDICATE_ROOTS, and --max-warnings is deliberately unset repo-wide, so they fail nothing. Said here rather than left for a reviewer to find.

Acceptance notes

  • Consequence, in scope and deliberate: the narrowing reaches every mount that declares scope="record", because the component has one forwarding site. The client-evaluated mounts (an action's visible / disabled) stop being OFFERED os / user / current_user, which their host really does bind. They still lint clean when typed, and one roots declaration at that mount restores the offer — the escape hatch is pinned by a case in the new suite rather than promised. Worth a follow-up card; it is not this one's to land under the declared file surface.
  • Finding, filed as out of scope: CONTEXT_SUBJECTS in this same file offers org.id as a row SUBJECT at every mount, and the record-scope lint hard-errors on it with the remedy record.org — the objectui#8155 app shape, in the subject dropdown rather than the autocomplete. org is not in @objectstack/formula's SCOPE_ROOTS at all. It also offers user.*, which no server-evaluated mount binds. Both reach a different control than this card's, and repairing them changes what the row builder EMITS, so they are not folded in here. Dedup words: ConditionBuilder CONTEXT_SUBJECTS · org root unbound · subject dropdown unbound root · SCOPE_ROOTS org.
  • Neighbour checked, no collision: claude/issue-9464-admin-scope-invalid-draft touches PermissionAdvancedFacets.tsx, its test, i18n.ts and a changeset. It does not touch ConditionBuilder.tsx.
  • Scope fence held: ConditionBuilder.tsx, one new sibling test beside it, one changeset. HookDefaultInspector.tsx is NOT edited — the trap closes through the record-scope default, which is why that default exists rather than a per-mount declaration at the hook mount.

Generated by Claude Code

… its host binds

objectui#8167 made the raw CEL editor lint in its mount's scope; its
autocomplete kept offering the engine's whole default advertisement for that
scope. On a hook `condition` the server binds `{ record, previous }` and throws
`HookConditionError` on an unevaluable one, so an accepted `os.user.id`
suggestion linted green and was refused at runtime.

A `scope="record"` mount now advertises RECORD_CONDITION_ROOTS; a mount that
binds more declares it through the new `roots` prop, as ObjectFieldInspector
and ConditionalFormattingEditor already do for their own surfaces. A mount that
declares no scope forwards `undefined` and is unchanged. Suggestions only — the
accept set is the engine's and is untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3049.9 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-ndvfV86E.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.74KB 130.75KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 215.98KB 59.97KB
fields (index.js) 249.23KB 62.88KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.61KB 20.05KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.37KB 65.90KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.81KB 34.17KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.61KB 57.90KB
plugin-kanban (index.js) 48.10KB 14.94KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.05KB 21.01KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 104.82KB 34.67KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@os-sales
os-sales marked this pull request as ready for review September 18, 2026 10:54
@os-sales
os-sales added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit df0d33d Sep 18, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-9645-condition-builder-roots-override branch September 18, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants