Skip to content

fix(console): suppress the Approvals Inbox record link when the viewer cannot read the target - #5347

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-5211-inbox-record-link-readability
Aug 19, 2026
Merged

fix(console): suppress the Approvals Inbox record link when the viewer cannot read the target#5347
os-support-ai merged 1 commit into
mainfrom
claude/issue-5211-inbox-record-link-readability

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #5211

Shipped: Option 2 (suppress the link), not the pre-ruled Option 1 fallback. The measurement that chose it: the readability probe is one batched list read per distinct object per load, and zero per-row round trips — asserted, not estimated. In the render test, a page of 2 rows sharing one object issues exactly 1 find call (expect(adapterFind).toHaveBeenCalledTimes(1)); at the plan level, 50 rows across 2 objects cost 2 calls, 50 rows of one object cost 1, ids are deduped, and a single object only splits past 100 ids. Each (object, id) is probed at most once per mount, so paging in another 50 rows costs only the new ids, and the drawer costs 0 extra for a row already in the list (at most 1 for a deep-linked request naming an object the page has not already covered). For scale: the inbox already spends 1 listRequests per load, plus 1 badge refresh on the non-pending tabs — the probe is the same order of traffic, not a multiple of the row count.

One thing I want visible rather than decided silently: the ruling's "expensive" clause also names "a call the inbox does not otherwise make", and this is a new call type — the inbox otherwise talks only to /approvals/*. I read that clause as describing per-row cost rather than as a separate disqualifier, because on the strict reading no probe could ever qualify (a probe the inbox already made would not be a probe), which would make Option 2 unreachable by construction while the ruling names it primary and calls it "batchable". The number above is the trade; if the maintainer reads the clause the other way, Option 1 is a small revert of the two render sites.

What changed

An approver is routed a request by position, but record visibility is a separate gate and nothing reconciles the two — so the row offered a record chip that, for the person it was offered to, landed on "Record not found — the record you are looking for does not exist or may have been deleted". Two render sites now show the record title as plain text instead of a link when the probe says this viewer cannot read the target:

  • apps/console/src/pages/system/ApprovalsInboxPage.tsxRecordCell (the row chip: the reported surface), and the drawer's record-title link. The drawer is included because it offers the same link to the same record for the same viewer; fixing only the row would move the dead end one click deeper rather than remove it.
  • Deliberately not suppressed, with a comment saying so: the "Edit record" button on a returned request. That branch renders only for the submitter, whose job there is to edit that record — a different persona from the approver this is for.

The title itself still renders: it comes from the request's own payload snapshot, which is what the approver decides from. The mobile card never rendered a link and is untouched.

New: apps/console/src/pages/system/recordReadability.tsplanReadabilityProbe (the cost model, so a test can assert the call count directly), probeRecordReadability, and the useRecordReadability hook. The probe is an ordinary id in (…) list read issued as the signed-in viewer under the viewer's own grants, projected to the id column, with $top pinned to the batch size so a server-side default page size cannot truncate the answer into a forged "unreadable".

Fail-open, always. Unknown, unanswered, failed, or no data source leaves the link exactly as it is today. This is an affordance, not an access control — the server stays the only authority — so the probe's failure mode must never withhold a link from someone who could have used it. One denied object cannot blank the others: failures are per group.

⛔ What this does not touch

The server's access semantics, in either direction. It answers 404 RECORD_NOT_FOUND rather than 403 deliberately — the record is filtered out of the row set before the by-id read, so declining to confirm its existence is the point. Nothing here says why a target is unreadable, no user-visible copy is added at all (hence no i18n keys, no locale packs, and check:i18n-keys / check:i18n-drift / check:i18n-dead-keys stay green), and no test asserts anything about 404-vs-403. Option 3 and Option 4 are not touched.

Verification — all at d8927ba1a

pnpm exec vitest run apps/console/ (repo root, the config CI uses): 57 files / 672 tests passed, including the 14 new ones. apps/console type-check and lint green (0 errors; the surviving warnings are pre-existing). Gates run and green: check:control-bytes, check:i18n-keys, check:i18n-drift, check:i18n-dead-keys, check:phantom-deps, check:self-import, check-changeset-presence, check-changeset-no-major. The workspace dependency closure was built before type-check — without it every @object-ui import reads as an unresolved module and the run is a false red.

Reverse verification — predicted before running, then observed

Restored only the page render change (probe module and all tests kept). Predicted: probe unit file all green; suppression RED; cost RED; the pure decision-path pin GREEN; suppressed-row-decidable RED; fail-open RED. Observed: exactly that — 4 failed / 10 passed. Direction was straight red, not a diagnostic-count change and not a reversal. The restore leg is byte-identical to HEAD (git status --porcelain empty) and back to 14/14.

No build artifact sits between the edit and the thing under test on either leg: the root Vitest config aliases every @object-ui specifier at that package's src, and the page and probe are apps/console source that Vite transforms directly. (The tsc type-check is the one tool here that does read the packages' built dist declarations — a different tool, not this test path.)

A first attempt at the pin was wrong and is worth recording. It opened by waiting for the suppressed link to be absent, which passes vacuously while the page is still a loading skeleton — so it went green on the pre-change tree, proving nothing. It is now split in two: one case pins inline approve without involving the probe at all (green on both trees, which is what makes it a pin), and one waits for the rows first and then for the link to vanish before deciding the suppressed row.

Changeset: patch (.changeset/lucky-buttons-clap.md) — user-visible, and never major.


Generated by Claude Code

…r cannot read the target

Approver routing goes by position; record visibility is a separate gate, and
nothing reconciles the two. An approver routed a request about a record they
cannot read was still offered the row's record chip, which landed on the record
page's "Record not found — the record you are looking for does not exist or may
have been deleted".

The row (and the drawer's record title, which offers the same link to the same
record for the same viewer) now render the title as plain text when a readability
probe says the target is unreadable. The title itself still shows: it comes from
the request's payload snapshot, which is what the approver decides from.

The probe is one batched `id in (…)` list read per distinct object, projected to
the id column, and each target is probed at most once per mount — measured at one
call for a page of rows sharing an object, independent of row count. It fails
open: unknown, unanswered or failed leaves the link exactly as it is today.

⛔ The server's access semantics are untouched and unreported: it answers 404
rather than 403 deliberately, so as not to confirm a record's existence to a
principal not permitted to see it. Nothing here says why a target is unreadable,
no copy is added, and no test asserts anything about 404-vs-403.

Part of #5211

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 25.3 KB 350 KB
Entry file index-nPpk6HJM.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.83KB 3.70KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.33KB 7.05KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.79KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.71KB 1.22KB
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.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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.53KB 113.13KB
core (index.js) 4.11KB 1.62KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 159.80KB 44.34KB
fields (index.js) 237.07KB 59.46KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.42KB 1.39KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 29.43KB 7.15KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 39.16KB 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.74KB
mobile (index.js) 1.50KB 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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 127.92KB 32.80KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 241.46KB 60.56KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 124.19KB 30.20KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 198.27KB 53.29KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.66KB 27.13KB
plugin-map (index.js) 20.08KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.52KB 20.67KB
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.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 36.10KB 12.26KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
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) 0.20KB 0.18KB
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-retry.js) 4.32KB 2.02KB
types (index.js) 3.08KB 1.53KB
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 (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
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

Copy link
Copy Markdown
Collaborator Author

ACCEPT — PM review, round 17.

  • Path surface: apps/console/src/pages/system/ApprovalsInboxPage.tsx, a new recordReadability.ts, two new test files, one changeset. Zero governed-surface hits.
  • Gates: every gate job completed: success — Lint, Type Check, Test shards 1–4, Build & E2E, Build Docs, Doc Snippet / Doc Component Type Check, Changeset Declaration / Bump Policy / Fixed Group, Bundle Analysis, Control Byte Scan, Internal Docs Link Check, Skill Guide Path Check. Plus check:i18n-keys / check:i18n-drift / check:i18n-dead-keys / check:phantom-deps / check:self-import green locally. No cancelled, no in_progress.
  • The rejected options stayed rejected, and visibly so: no copy is added anywhere (hence no i18n keys and no locale packs), nothing states why a target is unreadable, and no test mentions 404-vs-403. The server's semantics are untouched and unreported — which is the entire point of the ruling.
  • Fails open: unknown, failed, or no data source leaves the link exactly as today. The server stays the only authority; this is an affordance.

The measurement selected the branch, as the ruling required

One batched list read per distinct object per load, zero per-row round trips — asserted, not estimated:

scenario calls
2 rows sharing one object (render-level) 1, pinned by toHaveBeenCalledTimes(1) + exact params
50 rows / 2 objects 2
50 rows / 1 object 1
duplicate targets deduped to 1
single object past 100 ids splits

Each (object, id) is probed at most once per mount, so paging in another 50 rows costs only the new ids, and the drawer costs 0 extra for a row already listed. For scale: the inbox already spends 1 listRequests per load plus a badge refresh — the same order of traffic, not a multiple of the row count.

Two judgement calls I agree with

The drawer's record-title link is suppressed too. Same link, same record, same viewer — fixing only the row would have moved the dead end one click deeper. The "Edit record" button on a returned request is deliberately not suppressed, with a comment saying why: that branch renders only for the submitter, a different persona. Extending to one and not the other is the correct reading of "the person it is offered to".

The vacuous-pass the dev caught in its own work

The first decision-path pin opened by waiting for the suppressed link to be absent — which passes trivially while the page is still a loading skeleton. Measured green on the pre-change tree, i.e. proving nothing. It was split into two: one case that pins inline approve without involving the probe at all and is green on both trees (which is what makes it a pin), and one that waits for rows, then for the link to vanish, then decides the suppressed row. Finding and naming your own vacuous test is the hard half of this discipline.

Reverse-verification predicted 4 red / 10 green by name and observed exactly that, straight red, no direction reversal. No build artifact between edit and subject on either leg (root Vitest aliases @object-ui/* at each package's src; the page and probe are apps/console source); the one tool that does read built declarations is tsc, and its closure build is reported — including the note that without it every @object-ui import reads as unresolved and the run is a false red.

One reading recorded, not adjudicated as new policy

The ruling's "too expensive" clause also names "a call the inbox does not otherwise make", and this is a new call type — the inbox otherwise talks only to /approvals/*. The dev read that clause as describing per-row cost, on the ground that the strict reading would make no probe qualify and therefore render Option 2 unreachable, while the same ruling names Option 2 primary and calls it batchable. I agree, and I want to be precise about what that is: reading the maintainer's own text so its two halves are consistent, not substituting a decision. Flagged rather than settled silently, which is right. If the strict reading is preferred, Option 1 is a revert of two render sites.

Also filed: #5348ApprovalsInboxPage declares RequestCell/RecordCell/InlineActions inside its own component body, so each render creates a new component type and React remounts those cells; measured as a silently swallowed inline-Approve click when a re-render landed mid-interaction. Correctly filed rather than fixed here.

Merging via the queue.


Generated by Claude Code

@os-support-ai
os-support-ai added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit 8739c8e Aug 19, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5211-inbox-record-link-readability branch August 19, 2026 18:46
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.

finding: an approver routed a request can be unable to open the record it concerns — the inbox's record link dead-ends on "Record not found"

2 participants