Skip to content

fix(console): honour each row's own hidden: true in the approvals queue and its amount sort - #6364

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6020-approvals-queue-hidden-amount
Aug 25, 2026
Merged

fix(console): honour each row's own hidden: true in the approvals queue and its amount sort#6364
os-support-ai merged 2 commits into
mainfrom
claude/issue-6020-approvals-queue-hidden-amount

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #6020

The approvals inbox queue rendered, and sorted on, an amount field the object declares hidden: true.

The call-site census, re-derived on this branch's base (194fae184)

decisionAmountEntry already contained the filter — objectui#5565 added it — but the hiddenKeys parameter was optional, and one of five call sites passed it:

line surface passed hiddenKeys?
:1845 drawer summary card lead figure yes — the one #5565 wired
:444 RecordCell, desktop queue row no
:1649 mobile queue card no
:1061 sortKey === 'amount' comparator, left no
:1062 sortKey === 'amount' comparator, right no

Measured, not assumed: every line above was re-derived from origin/main at 194fae184, and all five matched the numbers on the card. A filter that lives in a function body but is unpassed at four of five call sites reads exactly like a repaired defect — the function body is not the measurement, the call sites are.

Why the obvious repair is wrong, and what this does instead

Threading the page's existing hiddenPayloadKeys into the four sites does not work. That set was useHiddenFields(selected?.object_name) — keyed to the drawer's currently-selected request. The queue is N rows spanning K objects, so one object's declarations would have been applied to every row: fields hidden on rows whose object never declared them, fields missed on rows whose object did. That is worse than the current miss, because it would look repaired.

So the lookup became per-object, on the batching pattern recordReadability.ts already established for this page:

  • useHiddenFields (one object) is replaced by useHiddenFieldsByObject(objectNames), returning a forObject(name) lookup. Every consumer asks with its own object_name.
  • planHiddenFieldReads(names) is the cost model, as planReadabilityProbe is for the readability probe: its returned length is exactly the number of metadata reads a render adds. Distinct objects only, first-seen order, non-names dropped. A page of N rows over K objects costs K reads, not N, and every object is read at most once per mount.
  • The lookup is fed the same targets as the readability probe (rows plus selected), so a deep-linked drawer whose request is not in the current row set is still covered.
  • decisionAmountEntry's hiddenKeys parameter is now required. That, not the four edits, is what closes the defect class: the compiler now asks whose hidden keys at every present and future call site. (payloadSummary keeps its optional parameter — a required parameter cannot follow the optional excludeKey without reordering the signature, and its one call site already passes the set.)

Ordering posture, stated with its acceptance criteria

The comparator half is not a visibility change, so it is declared rather than assumed: the queue orders on exactly the figure it renders. A row whose amount field is hidden loses its sort key and sinks with the other amount-less rows, keeping their relative newest-first order — which is the behaviour that surface already has for a request carrying no amount at all. The row itself never leaves the inbox; an approver still sees and can act on every request routed to them.

The reason this half is in scope: ordering is disclosure. Sorting on a hidden figure tells a viewer who never sees it how it compares with every other row, which leaks its relative magnitude.

When the hidden-key set is empty — fail-open, deliberately preserved

forObject returns the empty set for four distinct situations, deliberately collapsed into one answer: nothing declared hidden · this object was never asked about · the read has not answered yet · the read failed. All four render today's figure and keep today's ordering.

Nothing here converts that to fail-closed. This is a presentation filter, not an access control — the server stays the only authority, FLS redaction happens at serve time, and hidden is ruled UI-only (objectstack#10749; internal: true is the serialization primitive). 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.

When the empty set changes is unchanged too. Reads still resolve per object, so the first paint renders exactly what it renders today and the trim applies as the declarations arrive — the same shown-then-hidden transition the drawer has had since #5565, now also possible on a queue row, and on the amount ordering if a reviewer has already chosen that sort. It is bounded: the read is GET /api/v1/meta/object/:name, already on the adapter's MetadataCache (LRU, 5-minute TTL, in-flight de-duplication), so on any page whose objects the session has touched it is resolved before the first paint. Trading it for a fail-closed hold would mean withholding an amount from every approver whenever metadata is slow or unreadable, which is the failure direction hiddenFields.ts exists to refuse. Called out explicitly so it is reviewed rather than inherited.

Coverage — the fixture spans two objects on purpose

apps/console/src/pages/system/ApprovalsInboxPage.queueHiddenAmount.test.tsx, six tests through the page's own DOM. A single-object fixture would pass under the broken repair above, so the fixture is two objects with different declarations:

  • showcase_purchase declares total_amount hidden.
  • showcase_invoice declares service_fee hidden, and total_amount not.
row object amount key expected
PO-4417 purchase total_amount trimmed — the reported defect
INV-8801 invoice total_amount renders — the naive repair hides this
INV-8802 invoice service_fee trimmed — a one-object repair misses this

Three further properties keep it from passing by accident:

  • Promotion. PO-4417 carries a second, undeclared amount key (freight_cost) after the hidden one, and trimmed the row renders that. So the drop happens inside the scan, before the field is chosen, and "the amount is gone" cannot be satisfied by a row that simply stopped rendering amounts. Same idea as the 6-field-cut promotion in ApprovalsInboxPage.hiddenFieldTrim.test.tsx, transposed onto the pick.
  • Both surfaces, counted. The desktop table row and the md:hidden mobile card are both in the DOM here, so a rendered figure is exactly 2 leaf nodes and a trimmed one exactly 0. A repair that fixes one surface and forgets the other fails.
  • Counter-probes. Each half re-runs the same fixture with nothing declared hidden, where the figure renders and the row sorts where its amount puts it; every denial case also asserts the three record titles are still present, so an empty queue fails before it can pass. There is also a fail-open case (metadata read rejects) and a cost-model case (three rows, two objects, exactly two getObjectSchema calls).

The sort fixture's three orders are three different permutations, so "sorted", "not sorted" and "sorted without the hidden figure" can never be confused: default newest-first INV-8802 · INV-8801 · PO-4417; amount undeclared PO-4417 · INV-8801 · INV-8802; amount with the declaration PO-4417 · INV-8802 · INV-8801.

ApprovalsInboxPage.hiddenFieldTrim.test.tsx is untouched and still green — it covers the drawer, which was already correct, and it is now also the regression check on re-pointing the drawer at the shared lookup. planHiddenFieldReads is pinned by three unit tests in hiddenFields.test.ts.

Ablations — direction and counts predicted before running, both matched

No build artifact sits between the edit and the run: the page and the hook are this app's own source, imported directly by the test, and the root Vitest config aliases every @object-ui specifier at package source — so there is no dist to rebuild between mutation and measurement. Each mutation was proven on disk before its run (git blob hash changed, injected marker counted, removed anchor counted at zero) and each restore proven afterwards (git hash-object back to the HEAD blob, git diff HEAD empty).

A — the two render call sites neutered (hiddenFields.forObject(r.object_name) replaced by an empty set at RecordCell and the mobile card).
Predicted: RED, 2 failures — the render test, plus the render assertion inside the sort test, with that test's row-order assertion still passing because the comparator was untouched.
Actual: Tests 2 failed | 4 passed (6). Test 1 failed at the hidden purchase figure (expected length 0, got 2); the sort test passed expect(desktopRowTitles()).toEqual(['PO-4417', 'INV-8802', 'INV-8801']) and failed on the next line at amountNodes('USD 5,000.00') (0 vs 2). Prediction correct in direction, count and failing line.

B — the comparator neutered (both decisionAmountEntry arguments in the amount sort replaced by an empty set, render sites untouched).
Predicted: RED, 1 failure — the sort test, on row order, actual ['PO-4417', 'INV-8801', 'INV-8802'] against expected ['PO-4417', 'INV-8802', 'INV-8801'].
Actual: Tests 1 failed | 5 passed (6), AssertionError: expected [ 'PO-4417', 'INV-8801', 'INV-8802' ] to deeply equal [ 'PO-4417', 'INV-8802', 'INV-8801' ]. Prediction correct in direction, count and values.

Nothing was wrong in either prediction. The pair also settles T1 for review: the two halves fail independently, so neither is riding on the other's coverage.

Gates — run locally at ff0457c5, each quoted from its own verdict line

Gate set derived by reading the job step lists under .github/workflows/ (ci.yml, lint.yml, changeset-presence.yml, changeset-guard.yml, control-bytes.yml, vi-mock-specifiers.yml) and keeping what this diff can reach: four TypeScript files under apps/console plus one changeset.

gate verdict line exit
vitest run apps/console/ Test Files 79 passed (79) / Tests 896 passed (896) 0
pnpm --filter @object-ui/console type-check tsc --noEmit && tsc -b tsconfig.node.json --force, no diagnostics 0
eslint . in apps/console 180 files linted, errors=0 warnings=211 (JSON reporter counts) 0
check-changeset-presence.mjs 4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s) 0
check-changeset-no-major.mjs No changeset declares a major bump. 0
check-changeset-fixed.mjs All workspace packages are in the changeset fixed group. 0
check-control-bytes.mjs OK (scanned 5237 tracked text file(s); skipped 85 binary) 0
check-vi-mock-specifiers.mjs OK (… 686 relative specifier(s) resolved …) 0
check-lint-coverage.mjs 46/46 packages linted, 0 with outstanding errors 0
check-type-check-coverage.mjs 45/46 via type-check … 41/41 packages compile their tests 0
check-i18n-call-site-keys.mjs Every in-scope call-site key resolves against the en pack 0

Notes on how those were read, not just that they were run:

  • Every exit code was captured before any pipe (cmd redirected to a file; EXIT=$?), never from a tail downstream of the command.
  • Nothing here is narrowed: the lint and test rows are the whole apps/console package, which is the only package this diff touches.
  • The typecheck row is a real measurement of the new files, not a vacuous pass: tsc --noEmit --listFiles lists all four changed files, including both .test.tsx/.test.ts files, so the console project does not exclude tests from its check.
  • The typecheck required the dependency closure to be built first (pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build, exit 0) — apps/console has no paths mapping, so @object-ui/* resolves through each package's dist. Judging before that build would have read stale or missing .d.ts in either direction.
  • The two react-hooks/refs warnings on the new hook are the pattern recordReadability.ts already carries on main for the identical latest.current = … idiom (verified by linting that untouched file: same single warning). No new warning class.

Out of scope

No unrelated defect was found on this path. hidden stays UI-only here: nothing in this change touches serialization, permissions, or internal.


Generated by Claude Code

claude added 2 commits August 25, 2026 15:35
…ueue

The approvals queue rendered, and sorted on, an amount field the object
declares `hidden: true`. objectui#5565 put the filter inside
`decisionAmountEntry` behind an OPTIONAL `hiddenKeys` parameter and passed it
at one of five call sites — the drawer. The desktop row, the mobile card and
both halves of the amount comparator called it bare.

Passing the page's existing `hiddenPayloadKeys` at those sites would have been
wrong: that set is keyed to the OPEN request, while the queue is N rows
spanning K objects, so one object's declarations would have been applied to
every row.

- `hiddenFields.ts`: `useHiddenFields` (single object) becomes
  `useHiddenFieldsByObject`, a per-object lookup on the `useRecordReadability`
  batching pattern, plus `planHiddenFieldReads` as the cost model — one
  metadata read per distinct object per mount, not one per row.
- `decisionAmountEntry`'s `hiddenKeys` is now REQUIRED, so the compiler asks
  "whose hidden keys?" at every present and future call site. That is what
  closes the defect class rather than these four call sites.
- The amount comparator asks each row about its own object: the queue orders
  on exactly the figure it renders, and a row left with no renderable amount
  sinks with the other amount-less rows.

Fail-open is unchanged and deliberate: an unanswered or failed metadata read
is "nothing known to be hidden" and renders today's figure.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3223.5 KB 3266.6 KB
Main entry chunk (gzip) 154.1 KB 350 KB
Entry file index-BMWFt70A.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) 10.96KB 4.16KB
app-shell (runtime-config.js) 18.10KB 6.51KB
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) 5.13KB 2.35KB
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) 505.89KB 114.59KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 171.74KB 47.48KB
fields (index.js) 238.89KB 60.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
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.55KB 0.62KB
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 (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.21KB 44.67KB
plugin-dashboard (index.js) 133.35KB 34.45KB
plugin-designer (index.js) 211.95KB 42.75KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 127.52KB 31.01KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.21KB 54.43KB
plugin-kanban (index.js) 52.83KB 14.55KB
plugin-list (index.js) 111.94KB 27.24KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.70KB 7.69KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
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) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
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.49KB 2.14KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
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-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 16:16
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 9180fd1 Aug 25, 2026
28 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6020-approvals-queue-hidden-amount branch August 25, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Approvals inbox QUEUE rows still render (and sort on) an amount field the object declares hidden: true

2 participants