Skip to content

fix(app-shell): gate object fields on the spec-declared hidden / visibleWhen keys, not the refused visible - #6657

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-6514-field-visibility-declared-keys
Aug 28, 2026
Merged

fix(app-shell): gate object fields on the spec-declared hidden / visibleWhen keys, not the refused visible#6657
os-sales merged 3 commits into
mainfrom
claude/issue-6514-field-visibility-declared-keys

Conversation

@claude

@claudeclaudeBot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes#6514

Maintainer ruling, 2026-08-27, Option A: the two app-shell call sites stop gating on the
spec-refused fields[].visible and read the contract's DECLARED keys instead — the static
hidden (INVERTED polarity, per the spec's own guidance) and the predicate visibleWhen
— with the dead-key read deleted. Option B (adding field-level visible to the spec)
was declined. Dispatch seat 8ca04858-ea8e-5b85-9182-de59aa49e00c.

What changed

RecordFormPage and AppContent's global record-form modal both filtered an object's
fields with evaluateVisibility(f.visible, expressionEvaluator). FieldSchema is a
strictObject and visible is not one of its keys — it is prose in FIELD_KEY_GUIDANCE
that REFUSES the spelling, deliberately not an alias, because "this surface declares BOTH
forms and the two answers have opposite polarity". The gate was therefore unreachable
through the authoring surface.

Both sites now call one shared helper, isObjectFieldVisible (new, in
providers/ExpressionProvider.tsx — a module both already imported, so no new module edge
is created):

if(field?.hidden===true)returnfalse;returnevaluateVisibility(field?.visibleWhen,evaluator,'visibleWhen');

One helper rather than two edited expressions, deliberately: the polarity is the whole
risk of this card, and two copies of an inversion are two chances to get it backwards.

Composition is AND.visibleWhen is documented as "shown only when TRUE (else
hidden)" — a necessary condition, never a licence to un-hide a statically hidden field.
This is the same shape @object-ui/core's resolveFieldRuleState already uses one tier
down, where the static and the predicate OR into the restrictive verdict
(readonly || readonlyWhen, required || requiredWhen). Precedent, not invention.

The census, re-run on today's ref, WITH a positive control

The zero-usage measurement was taken at d09e13fd7 over 104 files. Re-run at framework
origin/mainaef1b7e641e7cead90f35db7cd1e06c46a021944113*.object.* files now
— with the control in the SAME query shape, so a zero is a measurement and not a broken
pattern:

field-level keyoccurrencesrole
visible0TARGET (spec-refused)
label113positive control
required107positive control
readonly76positive control
searchable32positive control

The controls prove the query reaches field level. visible: does occur 22 times as a code
key across those files — every one of them under actions, zero under fields — plus
16 mentions in comments/prose. Zero nested below field level (per-option and similar) as
well. The finding holds on today's ref.

Also measured and worth stating: hidden and visibleWhen are themselves at 0
field-level occurrences in the framework's shipped objects. The declared keys have no
authored pull today either; what changes here is that they now WORK when authored, which
is what "the platform says what it means" costs.

Both-direction pins, per call site, red before the fix

visible -> hidden is an INVERSION, and an inversion read backwards raises no error. It
produces a field that disappears with no diagnostic, or one that LEAKS to a user who must
not see it. So each key carries a hiding case AND its complement, at BOTH sites:

  • packages/app-shell/src/views/RecordFormPage.declaredVisibilityKeys.test.tsx
  • packages/app-shell/src/console/__tests__/AppContent.declaredVisibilityKeys.test.tsx

Leg 1 — against the pre-fix code: 12 failed / 4 passed (16). The 12 red are the six
per site: hidden: true absent, false visibleWhen absent, the per-user predicate,
the AND composition, and both dead-key cases. The 4 green are the complements
(hidden: false present, TRUE visibleWhen present) — they cannot be red before the fix,
because nothing hid them then.

Leg 2 — against a deliberately INVERTED implementation: 16 failed / 0 passed. This is
the leg that makes the complements load-bearing. Both polarities in isFieldVisible were
flipped (=== true to !== true, and the predicate negated) and the complements go red
with expected [] to include 'plain_note' — inverted logic hid everything, exactly the
failure mode the card names. A suite asserting only the hiding half would have passed this
leg.

The mutation was confirmed on disk before the run, not inferred from an editor exit code:
2 injected markers present, 0 occurrences of the original guard remaining, and the blob
hash moved (c0dc6e21 to ba43b49f). Restore proven the same way: git diff HEAD empty,
0 residual markers, and the file's blob hash back to c0dc6e21, identical to
HEAD:...ExpressionProvider.tsx. The mutation script carried a trap restore on
EXIT INT TERM with absolute paths. No dist is involved — vitest resolves these packages
to src — so there is no stale-build leg to report. Both legs were re-run at the final
commit, so every number here is anchored to the same tree.

After the fix: 43 files / 404 tests passed. The suite selection is a DECLARED narrowing
and it was derived, not guessed: every *.test.ts(x) in packages/app-shell whose subject
imports a changed symbol (ExpressionProvider, evaluateVisibility, isObjectFieldVisible)
or renders a changed component (RecordFormPage, AppContent) — 43 of them, which covers
both new suites, both triaged suites, every providers/ suite including the fault-diagnostic
pins, and the sidebar/palette wrappers of evaluateVisibility. A full-package run was
started earlier and is deliberately NOT quoted: the tree was edited while it was in flight,
so it is not a clean measurement. CI runs the whole farm across its four shards regardless.

Fixture triage — two existing suites carried the dead key

These authored visible: cel(...) on object fields because that was the key the page read
when they were written. Only the CARRIER key moved; every predicate text, user fixture and
assertion is unchanged, and neither pin is weakened:

  • views/RecordFormPage.predicateScope.test.tsx (4 fixtures) — its subject is which ROOTS
    the evaluator binds, which is independent of the key carrying the predicate.
  • providers/expressionUser.mountParity.test.tsx (3 fixtures) — its subject is the SHAPE
    of the identity a mount site publishes; likewise independent.

Both files gained a note saying why the spelling moved.

Also in the diff, and why

The helper is named isObjectFieldVisible, not the shorter isFieldVisible, because that
shorter name is already twice taken by functions answering different questions —
views/metadata-admin/inspectors/flow-node-config.ts gates a flow node's CONFIG inputs on
node type, and apps/console's FormPage evaluates the VIEW-level field predicate. Only
the new symbol was renamed; neither prior owner is touched.

evaluateVisibility gained an optional third parameter, authoredKey, defaulting to
'visible'. Strictly additive — every existing 2-argument caller is byte-for-byte
unaffected — and it exists because this change would otherwise make the fault diagnostic
LIE: a visibleWhen fault at a field site would have printed visible, sending an author
to grep for a key FieldSchema refuses. The doc paragraph on APP_SHELL_VISIBLE_SURFACE
that described nav and field faults sharing one dedupe entry was corrected in the same
stroke, since key is part of the dedupe tuple and they no longer do.

Fenced boundaries

  • No current_user binding is added at field level. The ruling's sub-clause is
    binding, and nothing here reaches it: isFieldVisible adds no roots, it hands
    visibleWhen to the evaluator the caller already built. The spec's documented
    fault-open at this tier is unchanged.
  • The evaluator roots are untouched. This is a call-site change. The roots settled by
    objectui#6493 are exactly as they were.
  • views/metadata-admin/predicate.ts was never reached — that file is held by another
    dev (objectui#6617) and nothing here goes near it.

Confidence gaps the analysis declared

Both were probed rather than silently closed.

  • Third-party readers of the same key name. Repo-wide, the only source readers of an
    object FIELD's .visible were these two call sites; everything else named visible is
    ActionDef.visible, action params, or a component key, all legal at their own tier.
    @object-ui/types' BaseFieldMetadata declares no visible, so a third-party
    TypeScript plugin reading field.visible would be reading an undeclared key.
    But a real hazard turned up next door: the framework's published docs TEACH a
    field-level visible in three mutually incompatible shapes, none spec-valid
    (concept.mdx section 3, layout-dsl.mdx "Device-Specific Visibility"). Recorded as
    objectstack#12935 — out of scope here, docs-only, and in the other repo.
  • Stored visible data in customer deployments, written past schema validation. Not
    measurable from this container; no customer data is reachable. What objectstack#12935
    adds is the most plausible SOURCE of any such data, which is the honest partial answer.

Verification

  • Type-check, DOWNSTREAM direction (--filter="...@object-ui/app-shell", the prefix form):
    39/39 turbo tasks successful, covering @object-ui/console,
    @object-ui/example-byo-backend-console and @object-ui/example-console-starter as well
    as app-shell's own build. All four touched test files were confirmed present in the
    tsconfig.test.json program via --listFiles, so "typecheck is clean" genuinely covers
    them.
  • Suites run FROM THE REPO ROOT (npx vitest run plus explicit file paths), never the package-scoped form.
  • Gates derived from the changed paths, each quoted from its own verdict line:
    check:control-bytes OK (5497 files) · check:vi-mock-specifiers OK · check:spec-symbols
    OK · check:designer-field-key-parity OK · check:self-import OK · check:phantom-deps
    OK · check:i18n-keys OK · check:esm-specifiers OK · check:readme-exports OK ·
    check-changeset-fixed OK · check-changeset-no-major OK.
    check:eager-closure is NOT MEASURED locally, not red: it needs
    apps/console/dist/eager-closure.json from a console vite build and says so itself
    ("a broken gauge, not a passing budget"). CI builds it. The diff adds no module edge at
    all — both call sites already imported this module — so the eager closure is
    structurally unchanged.
  • Lint, as a DECLARED narrowing: eslint . in packages/app-shell, the package containing
    every changed source file. Population read from eslint's own config; count read from
    --format json: 1008 files, 0 errors, all 7 touched files present in the report by
    name. Invariance for untouched files: eslint.config.js declares no projectService /
    parserOptions.project, so type-aware linting is off and this diff cannot move the
    verdict on a file it does not touch. CI runs the repo-wide farm regardless.
  • The changeset gate ruled the bump and refused nothing.

Gate union, lint, type-check, both pin legs and the ablation were all run at HEAD = f8d36099.


Generated by Claude Code

os-salesand others added 3 commits August 28, 2026 09:12
…o prior owners of isFieldVisible
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3237.8 KB3266.6 KB
Main entry chunk (gzip)157.3 KB350 KB
Entry fileindex-CX60ZdWi.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)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
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)507.87KB115.07KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.41KB34.47KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.01KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.62KB54.56KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
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)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
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)9.30KB3.22KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

Labels

Projects

None yet

1 participant

@os-sales