Skip to content

fix(console): trim hidden: true fields from the approvals drawer summary card - #6031

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-5565-approvals-summary-hidden-fields
Aug 24, 2026
Merged

fix(console): trim hidden: true fields from the approvals drawer summary card#6031
yinlianghui merged 1 commit into
mainfrom
claude/issue-5565-approvals-summary-hidden-fields

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#5565

What was wrong

payloadSummary in apps/console/src/pages/system/ApprovalsInboxPage.tsx built the drawer's business summary card from the request's payload_json snapshot behind five filters — PAYLOAD_SYSTEM_KEYS, the lead amount key, null/object/empty values, unresolved opaque ids, then the first 6 survivors — and no field-visibility filter of any kind. A field the object's metadata declares hidden: true was an ordinary scalar to that code, so if it landed in the first 6 survivors it rendered in the card, labelled via payload_labels.

Re-measured on the post-objectstack#11039 ref, and the defect still reproduces:

  • FLS-restricted fields are no longer the subject. They are redacted at serve time via getReadableFields (objectstack#11039, both doors) and never reach this page.
  • The non-FLS hidden: true field still arrives and still renders, by construction: the ruling below gives hidden no serialization semantic, so the producer keeps shipping it.
  • ApprovalsInboxPage held no field-level metadata — re-derived: every hidden occurrence in the file before this change is a Tailwind class, an aria-hidden, or prose. So this needed a metadata read wired in, not a filter tweak.

Why the fix is client-side, and why that is not a workaround

Maintainer ruling on objectstack#10749, verbatim: 「hidden: true stays UI-only; internal: true is the serialization primitive」.

A client-side trim is consumer-side compensation only when the producer shipped data it should not have. Under this ruling the producer is correct to ship a hidden field in the snapshot — hidden was never a wire concern, and a field an author wants off the wire is declared internal: true. hidden is a UI contract ("hidden from the default UI"), and this drawer card is default UI, so the UI is the authoritative enforcement point, not a compensating one. Server-side trimming of hidden when writing or serving payload_json is exactly the serialization semantic the ruling refused, and is not done here.

internal is deliberately not read anywhere in this change. They are distinct primitives.

What changed

  • New seam, page-local:apps/console/src/pages/system/hiddenFields.tshiddenFieldNames (pure, reads both served fields shapes: the record shape { name: def } and the array shape [{ name, ...def }]), readHiddenFields (never rejects), and useHiddenFields (one read per object per mount). It consumes useAdapter, already exported from @object-ui/app-shell and already used by this page's sibling recordReadability.ts.
  • payloadSummary takes a hiddenKeys set and drops those keys before the 6-field cut, so the next business field is promoted into the freed slot rather than the card silently rendering one row shorter. That ordering is what makes this a filter rather than a truncation.
  • The lead amount (decisionAmountEntry) takes the same trim at the drawer call site. Both halves read the same snapshot and sit in the same card, so filtering only the field grid would have moved a hidden amount-like field from the grid into the bold figure at the top of the very card being repaired. Named here because it is one step past the card's literal wording: it is the same defect class, mechanically the same guard, in the same JSX block, under the same gate family.

No new published export

Clause-② answer: no published surface widening.useObjectFields is not exported and was not exported; the fix does not need it. It reads object metadata through the adapter's getObjectSchema — the runtime read every record form and detail view already performs for an ordinary business user — rather than through the metadata-admin MetadataClient that useObjectFields uses. Nothing was added to packages/app-shell/src/index.ts; packages/app-shell/** is untouched in this PR.

Cost — a cached read, and what invalidates it

One getObjectSchema(object_name) per distinct object per mount, for the open request's object only:

  • The call lands on the adapter's own MetadataCache (LRU max 100, 5-minute TTL, in-flight de-duplication), so repeated drawer opens on one object cost zero round trips.
  • On top of that the hook keeps a per-mount "read once" ledger, so re-renders — the page's 60s clock, search typing, a drawer re-opening — cost nothing at all.
  • Invalidation: the adapter cache TTL, an explicit adapter clearCache() (which the shell issues on a locale switch), or a page reload. A hidden flag flipped in Studio while the page is open is picked up on the next reload — the same staleness useRecordReadability already accepts on this page, and this is a presentation flag, not a grant.

The queue rows are not trimmed here — different surface, N rows across K objects, and the amount sort is an ordering change with its own acceptance criteria. Filed as #6020.

It fails open, and that is deliberate

An unanswered metadata read (no getObjectSchema on the source, a 404, a 403, a transport error) leaves the declaration unknown and renders the field — today's card. The server is still the only authority on what a principal may read and has already answered by the time the payload arrives; degrading an approver's decision surface on a transient metadata error would break the primary workflow to enforce a declaration that was never the security boundary. This is the same direction recordReadability fails on this page, and the opposite of #5553's raw-JSON panel — there the measured defect was a non-holder seeing the panel, so absence of an answer had to deny. Pinned by a test.

#5553's "Raw data (JSON)" panel gate is untouched and its ruling is not reopened.

Verification

Predicted before running, then observed. Full local union re-run on the final commit 588c2c5a5.

Path-filtered vitest (app-shell whole-package vitest deliberately not run):

pnpm exec vitest run \
apps/console/src/pages/system/hiddenFields.test.ts \
apps/console/src/pages/system/ApprovalsInboxPage.hiddenFieldTrim.test.tsx \
apps/console/src/pages/system/ApprovalsInboxPage.rawPayloadGate.test.tsx \
apps/console/src/pages/system/ApprovalsInboxPage.cellIdentity.test.tsx \
apps/console/src/pages/system/ApprovalsInboxPage.recordLink.test.tsx \
apps/console/src/pages/system/ApprovalsInboxPage.stepProgressVertical.test.tsx \
apps/console/src/pages/system/recordReadability.test.ts --maxWorkers=2
→ Test Files 7 passed (7) · Tests 37 passed (37) · VITEST_EXIT=0

The fixture pins the filter, not the render. The 6-field cut is a confounder in both directions, so the snapshot is ordered:

1 subject · 2 vendor · 3 diagnosis_code (the hidden one, well inside the cut) · 4 department · 5 urgency · 6 ledger_ref · 7 justification · 8 notes

Untrimmed the card shows 1-6. Trimmed it shows 1, 2, 4, 5, 6 and 7 — the seventh is promoted into the vacated slot, which can only happen if the drop precedes the cut. notes (8) stays out either way, so "promotion" cannot be an off-by-one that simply renders more rows.

Counter-probes (the important half — "the hidden field is gone" is satisfiable by breaking the card entirely):

Reverse verification (two ablations, each with the direction predicted first):

ablationpredictedobserved
remove the guard in payloadSummaryRED on the promotion test (the 7th never renders, so findByText times out before the hidden-field assertion is reached) — and additionally RED on the hidden-amount test, because decisionAmountEntry still drops the amount, leaving excludeKey undefined so the ungated grid renders that same figure by a different routeexactly that: 2 failed / 10 passed; both counter-probes and the fail-open test stayed green
remove the guard in decisionAmountEntryRED only on the hidden-amount testexactly that: 1 failed / 11 passed

Each mutation was proved on disk before the run — the injected marker grepped (1 hit) and separately the remaining guard counted (1 hit, so exactly one of the two identical guards was removed) — because an editor's exit code proves nothing on a zero-hit anchor. Both ran under trap … EXIT INT TERM; git diff HEAD --stat was empty after each. No build artifact sits between the edit and the ablation: the root Vitest config aliases every @object-ui specifier at that package's source directory, and the page under test is this app's own source, imported relatively.

Gates, by name, with exit codes captured before any pipe:

gateexitnote
pnpm --filter @object-ui/app-shell type-checkAPPSHELL_TC_EXIT=0echoed tsc --noEmit && tsc -p tsconfig.test.json, so not a zero-match silent pass
pnpm --filter @object-ui/console type-checkCONSOLE_TC_EXIT=0echoed tsc --noEmit && tsc -b tsconfig.node.json --force; the first run was a genuine RED (TS2322 on the test fixture's inferred row type), repaired, and not an unbuilt-closure signature
pnpm --filter "@object-ui/console^..." buildBUILD_EXIT=0dependency closure built before judging any type-check
pnpm exec eslint --no-inline-config on the 4 changed source filesESLINT_EXIT=00 errors, 20 warnings — all 20 pre-existing: eslint on the merge-base copy of ApprovalsInboxPage.tsx also reports exactly 20, and no warning is on a touched line. The three new files report nothing.
node scripts/check-changeset-presence.mjsCHANGESET_EXIT=0run rather than guessed; a real patch changeset for @object-ui/console is owed and added (this is user-visible behaviour, not the empty-frontmatter case)
node scripts/check-control-bytes.mjsCTRL_GATE_EXIT=0scanned 4975 tracked text files

Merge-base c0091b82b; the changed-file delta against it is the 5 files in this PR and nothing else.

Repo-wide pnpm lint is CI's run and is not duplicated here.


Generated by Claude Code

…mmary card
`payloadSummary` built the drawer's business summary card from the request's
`payload_json` snapshot behind five filters — `PAYLOAD_SYSTEM_KEYS`, the lead
amount key, null/object/empty values, unresolved opaque ids, a six-field cut —
and no field-visibility filter of any kind. A field the object's metadata
declares `hidden: true` was an ordinary scalar to that code, so it rendered in
the card, labelled via `payload_labels`.
Per the platform ruling, `hidden: true` stays UI-only and `internal: true` is
the serialization primitive: the producer is correct to ship a `hidden` field
in the snapshot, and FLS-restricted fields are already redacted at serve time.
`hidden` is a UI contract and this drawer card is default UI, so the UI is the
authoritative place that contract is enforced.
The drawer now reads the open request's object metadata (`getObjectSchema`, the
same cached `GET /meta/object/:name` the record form performs) and drops the
declared-hidden keys BEFORE the six-field cut, so the next business field is
promoted into the freed slot. The lead amount figure at the top of the same
card takes the same trim. An unanswered metadata read leaves the card exactly
as it renders today: this is a presentation filter, not an access control.
No new published export: the seam consumes `useAdapter`, already exported from
`@object-ui/app-shell` and already used by this page's `recordReadability`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3233.0 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-BExBzVZ8.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.38KB3.90KB
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.23KB114.56KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
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.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.12KB61.87KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
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)8.50KB2.88KB
plugin-view (index.js)84.57KB20.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)52.40KB17.45KB
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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Approvals drawer summary card can render a field the object declares hidden: true

2 participants

@yinlianghui@os-litant