Skip to content

Approvals Inbox: the raw payload panel is a platform-operator affordance, not the approver's read path - #5563

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-5553-approvals-raw-payload-panel
Aug 21, 2026
Merged

Approvals Inbox: the raw payload panel is a platform-operator affordance, not the approver's read path#5563
os-sales merged 2 commits into
mainfrom
claude/issue-5553-approvals-raw-payload-panel

Conversation

@os-sales

@os-salesos-sales commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5553

Verified at d96c3727f — every gate verdict below was read from a run on that exact tree, after the final commit.

The defect

The detail drawer's "Raw data (JSON)" panel rendered on payload != null alone — no principal check of any kind — so every business approver could expand (and one-click copy) the submitted record's complete raw row: id, created_by, updated_by, owner_id, organization_id, bare lookup ids, and the fields the object's metadata declares hidden: true. Reported from a live EHR deployment on 17.1.0 (objectstack-ai/objectstack#10734, @baozhoutao), where that declaration is a patient-data control. The app author had no legitimate lever to remove it — field hidden, view columns, app navigation, permission sets and env vars are all ineffective — so the remedies available in the field were patching the shipped bundle or injecting CSS.

Premise re-verified against origin/main at 0935a43be before editing, not relayed: the panel was still live and still unconditional. PR #5509 (9b9af8d, the row-remount fix) had already landed in this file and did not touch the panel.

The fix

The panel is gated on holdsStudioAccess, reused verbatim from this app's studioEntry module. studio.access is a declared platform-scope capability a tenant org owner does not hold by design (one of the framework's PLATFORM_ADMIN_ONLY_CAPABILITIES), and it already reaches the browser in systemPermissions[] from /api/v1/auth/me/permissions — the payload MePermissionsProvider mounts around every route this page renders under. Nothing new is served, computed, or made authorable: no new config key (the card forbids minting one, and none was needed), no new i18n copy, and the panel is byte-for-byte unchanged for the platform operator it was written for. A business approver keeps the structured record summary, the approval chain, the activity feed and the decision actions; only the raw snapshot is gone.

Reusing studioEntry's predicate rather than mirroring the framework's four-capability platform-admin probe into the renderer is deliberate: one definition of "is this principal a platform operator rather than a business user", so the two surfaces cannot drift into two spellings of one fact (AGENTS.md #0.1). The narrower gate is also the safer one here — manage_users is in the server's probe, and a user administrator has no business reading raw clinical payloads.

Fail CLOSED — inverted from hasCapabilities

The gate reads the rawsystemPermissions signal, not usePermissions().hasCapabilities. That hook fails open on purpose, and that is right for an action button: the server still refuses the write, and hiding a holder's button is the worse outcome. This panel has the opposite stake — the measured defect is a non-holder seeing it — so every not-a-reported-grant answer denies: no provider mounted, a backend predating ADR-0066 that omits the field, the resolver's catch path that answers 200 with no systemPermissions at all, and a reported empty array. A deployment whose permission layer just failed must not be the one that leaks the snapshot. This is the same inversion studioEntry already documents for the /studio/* route gate, and it is why the undefined-vs-[] distinction objectui#4656 preserved is load-bearing here.

Tests, and how the vacuum is guarded

The acceptance condition is that something does not render — which an empty render reproduces perfectly. So in ApprovalsInboxPage.rawPayloadGate.test.tsx every denial case also asserts the drawer it is denying inside (process label + business summary row), and the studio.access case drives the same fixture through the same helper and finds the panel. An empty render fails the counter-probe; a gate stuck open fails the denials; neither can pass alone.

created_by and organization_id are the witnesses rather than proxies: both are in the page's PAYLOAD_SYSTEM_KEYS, so the summary card already drops them and their values can reach the DOM only through the raw panel.

Ablation (pure source — the root Vitest config aliases every @object-ui specifier at that package's source, so no build artifact sits between the edit and the run). The mutation was proven on disk before the measurement, not inferred from an editor exit code: the gated form maySeeRawPayload && selected.payload != null went 1 → 0 occurrences and the bare selected.payload != null && ( went 0 → 1, with git diff --stat showing the single-line change. Restoring the bare condition turns 3 of 4 cases red — the three denial cases, each on the Raw data (JSON) assertion, with the failure naming the found summary element it had just found in the DOM — and leaves the studio.access case green, which is exactly the predicted direction. The mutation script carried a trap … EXIT INT TERM restore, and the tree came back byte-identical (git status --short and git diff --stat both empty).

The rawData locale key stays

Deliberate, not incidental: the fix gates the panel rather than removing it, so tr('rawData', 'Raw data (JSON)') remains a live call site and the label still renders for a holder. check:i18n-dead-keys is green and no locale table is touched — removing the key would have meant editing ~10 locale files, outside this card's file fence, to delete copy that is still reachable.

Gates run locally at d96c3727f

All verdicts quoted from each gate's own output line, with exit codes captured before any pipe.

GateVerdict
apps/console vitest (whole project)Test Files 63 passed (63) · Tests 704 passed (704)
apps/console/src/pages/system/ (final commit)Test Files 10 passed (10) · Tests 64 passed (64)
@object-ui/console type-checkexit 0 (tsc --noEmit && tsc -b tsconfig.node.json --force echoed — not a zero-match pass)
@object-ui/console lint✖ 201 problems (0 errors, 201 warnings) — all pre-existing
check-changeset-fixed / -no-major / -presenceexit 0 / 0 / 0
check-control-bytesexit 0
check:i18n-keys / i18n-drift / i18n-dead-keysexit 0 / 0 / 0
check:eager-closure✅ Console eager closure is 3784.8 KB gzipped across 52 of 508 chunks (budget: 3867.2 KB, headroom: 82.4 KB)
check:phantom-deps / check:self-import / check:esm-specifiersexit 0 / 0 / 0
check-lint-coverage / check-type-check-coverageexit 0 / 0

Lint scope, stated so it is checkable: eslint . in apps/console linted 165 files (count read from --format json, population from ESLint's own config resolution, not a hand-picked list) with 0 errors and 201 pre-existing warnings. My new test file reports 0 errors / 0 warnings; ApprovalsInboxPage.tsx reports 19 warnings, all on pre-existing lines (the lowest is line 522, the buildApproverIdentities(user as any) line that predates this change; the added block starts at 524). Type-aware linting is not enabled in the root eslint.config.js — no projectService, no parserOptions.project — so this diff cannot move the verdict of any file it does not touch. The repo-wide pnpm lint and pnpm type-check farms are CI's run and are not duplicated here.

check:eager-closure was first read as a broken gauge (No eager-closure report … the console was not built), which is the state of any fresh worktree, not a finding about this diff. It was resolved by actually building the console rather than by argument. The structural expectation held: studioEntry is already in the eager closure via App.tsxStudioRoute, and ApprovalsInboxPage stays lazily loaded, so the import adds nothing eager.

Scope

apps/console/src/pages/system/ApprovalsInboxPage.tsx, its new test, and a changeset — the dispatched file fence, not breached.

Out of scope, deliberately and not silently: trimming the summary card by object metadata, and the server-side residual that sends the unfiltered snapshot to the client at all (tracked separately in the objectstack repo). Neither is asserted here, because asserting them would pin behaviour this change does not deliver. objectstack-ai/objectstack#10734 remains open as the originating report.


Generated by Claude Code

os-salesand others added 2 commits August 21, 2026 12:20
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3784.8 KB3867.2 KB
Main entry chunk (gzip)151.2 KB350 KB
Entry fileindex-CJK8GSzr.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 (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
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)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
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.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
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)506.94KB113.63KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)237.52KB59.62KB
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.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
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.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
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.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.51KB32.94KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.52KB20.67KB
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)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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)0.20KB0.18KB
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-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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