fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites - #7428

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites
Sep 3, 2026
Merged

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites#7428
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7230

All five sites land. The eager-closure constraint the card carried did not bind — measured, not assumed; figures below.

What an unauthorised principal could ask for before, and cannot after

objectui#7215 / PR #7229 FLS-gated the $expand projection at the two sites in its
scope (ObjectGrid, ListView). The projection is built in more places than that. This
closes the five the card names.

siteshape on mainverdict
plugin-calendar/src/ObjectCalendar.tsx:351buildExpandFields(objectSchema?.fields)no column listgated
plugin-gantt/src/ObjectGantt.tsx:675same, no column listgated
app-shell/src/views/RecordDetailView.tsx:402same, no column listgated
plugin-detail/src/DetailView.tsx:526passes a column list — already INPUT-gatedgated on the OUTPUT
plugin-dashboard/src/ObjectDataTable.tsx:675own whitelist via computeLookupExpandgated

(The card's line numbers were 350 / 674 / 680; the first two are off by one and the
dashboard one by five on current main. Same calls.)

The first three are the sharp ones.buildExpandFields reads an absent column list as
"no column restriction" and falls back to every declared relation on the object, denied
ones included. So a standalone calendar, a gantt, and every record page in the console asked
the server to resolve the object's full relation set by default rather than by
configuration
. $select on a denied lookup asks for a bare foreign key; $expand asks the
server to resolve it and hand back the related record — the larger of the two requests was
the ungated one.

DetailView is not what the card says it is, and the difference matters

The card lists it as "passes a column list, ungated". Measured on main, it is input-gated:
allFields is collected from schema, which is gatedSchema — already FLS-filtered field by
field, and whose own comment names "$expand build" among the uses it means to protect.

So this site is a live instance of exactly the route PR #7229 measured as unsound and rejected.
The consequence is not "a denied lookup slips through in the ordinary case" — it is worse and
narrower:

buildExpandFields reads an empty column list as "no column restriction" and falls back
to every declared relation.

⇒ Filtering the input WIDENS the request precisely where the principal may read least. A
detail view whose authored fields are all denied had its column list gated down to [] and its
$expand widened from the relations it asked for to every relation the object declares. The
same widening is reached with no authored field list at all, where the input filter has nothing
to remove and the expansion is maximal from the start. Both are pinned.

The input filter stays — it is load-bearing for the render half — but it is no longer what
decides the projection.

Reachability and grading — stated narrowly

  • Reachable in an ordinary configuration, on the client-request side. Three of the five need
    no authored column list at all, which is their default shape.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for a
    mechanism read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] on every unreadable key and objectql's expand path writes the resolved
    record back under that same key, so one statement removes the expanded object and the bare
    id alike; the expansion sub-read itself takes the referenced object's full CRUD + RLS + FLS
    treatment (objectstack#7626). Same grading objectui#6898 and $expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 were given, for the same
    reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use.

The fix — #7229's shape copied, not re-derived

The gate is on each helper's OUTPUT at every site. On the three no-column-list sites the
input-side alternative is not merely unsound but unreachable: the call passes undefined, so
there is no input to gate. Gating the output also satisfies the ordering requirement
structurally rather than by convention — buildExpandFields returns a subset of the object's
declared reference-bearing fields, and computeLookupExpand resolves both of its arms
through the object schema's own field map, so every name either gate judges is declared by
construction. The "checkField answers false for an undeclared key" trap is therefore
unreachable and a derived / host-joined column is never judged. Neither helper is changed.

An unanswered policy filters nothing (isLoaded: false is the no-provider default, forever), and
perms is in each site's dependency list, so the projection is rebuilt the moment the answer lands.

One structural note in RecordDetailView, load-bearing rather than cosmetic:usePermissions()
moved ~670 lines up, above the record-load effect. An effect's dependency array is evaluated
during render, so listing perms there while the binding was still declared below would throw
Cannot access 'perms' before initialization — a crash, not a stale value. The later site now
destructures that one value instead of calling the hook again, so the hook order is unchanged in
shape. Same lesson PR #7229 recorded for ListView's memo.

@object-ui/permissions is added as a dependency of plugin-calendar, plugin-gantt and
plugin-dashboard (the other two already had it). check:phantom-deps passes.

⚠️ The eager-closure constraint — measured, and it does not bind

The card required this be measured rather than assumed, and required the app-shell site to be
dropped if it put the framework chunk over its ceiling. It does not. Two full console
builds in the same container, main (bf244f400) versus this branch's final commit 96a284fe2,
read from apps/console/dist/eager-closure.json (exact gzip bytes, not the gate's rounded KB):

chunkbefore (bf244f400)after (96a284fe2)delta
framework523,823 B523,823 B0
vendor-objectstack948,329 B948,329 B0
ui-components396,598 B396,598 B0
aggregate eager closure3,254,545 B3,254,604 B+59 B
eager chunk count48 of 51648 of 5160

framework did not move by one byte, so its 177 B of headroom against the 524,000 B ceiling is
untouched and #7399 is not blocked by this PR. The emitted chunks confirm the mechanism rather
than a source-level guess: all three chunks above kept their content hash
(framework-Cf7M9oOC.js, ui-components-B7CUYYAi.js, vendor-objectstack-CAAM4J8I.js) — they
are byte-identical files.

Why the app-shell site was never a framework risk. The framework group in
apps/console/vite.config.ts is /packages[\\/](core|react|types)[\\/]/app-shell is not a
member and cannot route there. Read off the emitted graph, the whole +59 B is attributed:

assets/RecordDetailView-*.js 35,315 -> 35,346 +31 (its own EAGER chunk)
assets/plugins-views-*.js 71,833 -> 71,858 +25 (plugin-detail)
assets/index-*.js 146,915 -> 146,917 +2
assets/InterfaceListPage-*.js 3,626 -> 3,627 +1

plugin-calendar, plugin-gantt and plugin-dashboard are not in the eager closure at all
0 bytes, confirmed by their absence from the report's file list rather than by assuming "this
package is lazy". The aggregate has 13,396 B of headroom and check:eager-closure passes on both
builds.

⛔ No ceiling was raised, no baseline re-pinned, no gate weakened.

Tests — reproduced first, one file per site

New, all mirroring plugin-grid/src/__tests__/expandFls-7215.test.tsx:

filepre-fixpost-fix
plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx2 failed | 4 passed (6)6 passed
plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx5 failed | 2 passed (7)7 passed

Which pins discriminate, said plainly.DetailView's split is the interesting one and was
predicted before the run: its input filter already delivers the ordinary case, so PIN 1 (denied
lookup among readable authored fields) and PIN 2 (the readable-lookup control) are green in both
directions
and are labelled as such in the file. The two reds are the widening — every authored
field denied, and no authored field list at all — which is what this card actually buys at that
site. A green pin proves nothing on its own; naming the non-discriminating ones is the discipline
RecordDetailView.sectionHeadingsRenderPath-6190.test.tsx records.

Every file carries the live controls, not just the reds: a permitted lookup still expands (a
gate that emptied the expansion would paint raw foreign-key ids — the failure objectui#6453 fixed
on the calendar); master_detail is pinned beside lookup; an undeclared derived column is
untouched and does not take the expansion down with it; an unanswered policy filters nothing;
and the all-denied case yields no $expand rather than a widened one. Each harness waits on a
real recorded call — and, where the schema arrives asynchronously, on the schema-dependent
query specifically — so "the component stopped fetching" times out instead of reading as an empty
expansion.

Gates run locally, on the final commit 96a284fe2

gateresult
vitestplugin-calendar, plugin-gantt, plugin-dashboard, plugin-detail (full packages)293 files, 2565 tests passed
vitest — all 24 RecordDetailView.*.test.tsx24 files, 174 tests passed
vitest — all 19 other app-shell tests naming RecordDetailView19 files, 258 tests passed
type-check — all 5 packages (tsc --noEmit && tsc -p tsconfig.test.json)exit 0, scope 5 of 47
turbo run lint — all 5 packages in fullexit 0 — 0 errors (2885 pre-existing warnings)
check:eager-closure✅ on both builds (figures above)
check:phantom-deps✅ every in-scope import declared by its publishing package
check:control-bytes✅ 6116 tracked text files
check:vi-mock-specifiers / check:vi-mock-inherit✅ / ✅
check:self-import / check:side-effects-array / check:sdui-registration-pins✅ / ✅ / ✅
check-changeset-presence / check-changeset-no-major✅ / ✅

Declared narrowing.app-shell carries 608 test files; the four plugin packages alone took
9m of a ~10m foreground ceiling, so the full app-shell suite was not run locally — the 43 files
above are every test naming RecordDetailView, which is the complete set this diff can reach in
that package. Lint was not narrowed: eslint . ran in full for all five packages, and the
config is not type-aware (no parserOptions.project / projectService), so this diff cannot
move the verdict of any file it does not itself contain. CI runs the whole farm regardless.

Out of scope, filed rather than fixed

The card states buildExpandFields "is called from five more places". Measured on current main,
it is called from eleven production places outside #7229's two. Seven remain ungated after this
PR — kanban, tree, ObjectView, map, gallery, timeline and the metadata-admin PagePreview — and
all seven pass no column list, i.e. the same sharp shape as the three sharpest here. Filed
separately with the per-site measurements; not touched here, because they are outside this card's
declared file surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…hell): FLS-gate `$expand` at the five remaining build sites
objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in
its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own
whitelist) are reached from more places; this closes the five the card names.
Three of them — calendar, gantt and the record page — pass NO column list, so
the helper falls back to every declared relation on the object, denied ones
included: the maximal ask, by default rather than by configuration. DetailView
was INPUT-gated, which is the route #7229 measured as unsound: an emptied column
list reads as "no restriction" and WIDENS the request.
The gate is on the helper's OUTPUT at every site, copied from #7229 rather than
re-derived, so the "checkField answers false for an undeclared key" trap stays
structurally unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
…g build sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-BDsrk2PZ.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.14KB13.02KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.79KB34.62KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.27KB64.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.18KB40.94KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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.74KB2.25KB
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)8.11KB3.32KB
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

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

The out-of-scope finding named at the end of the description is filed as #7429 — the seven buildExpandFields call sites that stay ungated after this PR (kanban, tree, ObjectView, map, gallery, timeline, metadata-admin PagePreview), with the per-site measurements and a triage sizing note. Unassigned; not touched here.

Two notes on the description itself, recorded rather than silently corrected:


Generated by Claude Code

Sign up for freeto 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.

FLS-gate $expand at the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites - #7428

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites
Sep 3, 2026
Merged

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites#7428
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7230

All five sites land. The eager-closure constraint the card carried did not bind — measured, not assumed; figures below.

What an unauthorised principal could ask for before, and cannot after

objectui#7215 / PR #7229 FLS-gated the $expand projection at the two sites in its
scope (ObjectGrid, ListView). The projection is built in more places than that. This
closes the five the card names.

siteshape on mainverdict
plugin-calendar/src/ObjectCalendar.tsx:351buildExpandFields(objectSchema?.fields)no column listgated
plugin-gantt/src/ObjectGantt.tsx:675same, no column listgated
app-shell/src/views/RecordDetailView.tsx:402same, no column listgated
plugin-detail/src/DetailView.tsx:526passes a column list — already INPUT-gatedgated on the OUTPUT
plugin-dashboard/src/ObjectDataTable.tsx:675own whitelist via computeLookupExpandgated

(The card's line numbers were 350 / 674 / 680; the first two are off by one and the
dashboard one by five on current main. Same calls.)

The first three are the sharp ones.buildExpandFields reads an absent column list as
"no column restriction" and falls back to every declared relation on the object, denied
ones included. So a standalone calendar, a gantt, and every record page in the console asked
the server to resolve the object's full relation set by default rather than by
configuration
. $select on a denied lookup asks for a bare foreign key; $expand asks the
server to resolve it and hand back the related record — the larger of the two requests was
the ungated one.

DetailView is not what the card says it is, and the difference matters

The card lists it as "passes a column list, ungated". Measured on main, it is input-gated:
allFields is collected from schema, which is gatedSchema — already FLS-filtered field by
field, and whose own comment names "$expand build" among the uses it means to protect.

So this site is a live instance of exactly the route PR #7229 measured as unsound and rejected.
The consequence is not "a denied lookup slips through in the ordinary case" — it is worse and
narrower:

buildExpandFields reads an empty column list as "no column restriction" and falls back
to every declared relation.

⇒ Filtering the input WIDENS the request precisely where the principal may read least. A
detail view whose authored fields are all denied had its column list gated down to [] and its
$expand widened from the relations it asked for to every relation the object declares. The
same widening is reached with no authored field list at all, where the input filter has nothing
to remove and the expansion is maximal from the start. Both are pinned.

The input filter stays — it is load-bearing for the render half — but it is no longer what
decides the projection.

Reachability and grading — stated narrowly

  • Reachable in an ordinary configuration, on the client-request side. Three of the five need
    no authored column list at all, which is their default shape.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for a
    mechanism read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] on every unreadable key and objectql's expand path writes the resolved
    record back under that same key, so one statement removes the expanded object and the bare
    id alike; the expansion sub-read itself takes the referenced object's full CRUD + RLS + FLS
    treatment (objectstack#7626). Same grading objectui#6898 and $expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 were given, for the same
    reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use.

The fix — #7229's shape copied, not re-derived

The gate is on each helper's OUTPUT at every site. On the three no-column-list sites the
input-side alternative is not merely unsound but unreachable: the call passes undefined, so
there is no input to gate. Gating the output also satisfies the ordering requirement
structurally rather than by convention — buildExpandFields returns a subset of the object's
declared reference-bearing fields, and computeLookupExpand resolves both of its arms
through the object schema's own field map, so every name either gate judges is declared by
construction. The "checkField answers false for an undeclared key" trap is therefore
unreachable and a derived / host-joined column is never judged. Neither helper is changed.

An unanswered policy filters nothing (isLoaded: false is the no-provider default, forever), and
perms is in each site's dependency list, so the projection is rebuilt the moment the answer lands.

One structural note in RecordDetailView, load-bearing rather than cosmetic:usePermissions()
moved ~670 lines up, above the record-load effect. An effect's dependency array is evaluated
during render, so listing perms there while the binding was still declared below would throw
Cannot access 'perms' before initialization — a crash, not a stale value. The later site now
destructures that one value instead of calling the hook again, so the hook order is unchanged in
shape. Same lesson PR #7229 recorded for ListView's memo.

@object-ui/permissions is added as a dependency of plugin-calendar, plugin-gantt and
plugin-dashboard (the other two already had it). check:phantom-deps passes.

⚠️ The eager-closure constraint — measured, and it does not bind

The card required this be measured rather than assumed, and required the app-shell site to be
dropped if it put the framework chunk over its ceiling. It does not. Two full console
builds in the same container, main (bf244f400) versus this branch's final commit 96a284fe2,
read from apps/console/dist/eager-closure.json (exact gzip bytes, not the gate's rounded KB):

chunkbefore (bf244f400)after (96a284fe2)delta
framework523,823 B523,823 B0
vendor-objectstack948,329 B948,329 B0
ui-components396,598 B396,598 B0
aggregate eager closure3,254,545 B3,254,604 B+59 B
eager chunk count48 of 51648 of 5160

framework did not move by one byte, so its 177 B of headroom against the 524,000 B ceiling is
untouched and #7399 is not blocked by this PR. The emitted chunks confirm the mechanism rather
than a source-level guess: all three chunks above kept their content hash
(framework-Cf7M9oOC.js, ui-components-B7CUYYAi.js, vendor-objectstack-CAAM4J8I.js) — they
are byte-identical files.

Why the app-shell site was never a framework risk. The framework group in
apps/console/vite.config.ts is /packages[\\/](core|react|types)[\\/]/app-shell is not a
member and cannot route there. Read off the emitted graph, the whole +59 B is attributed:

assets/RecordDetailView-*.js 35,315 -> 35,346 +31 (its own EAGER chunk)
assets/plugins-views-*.js 71,833 -> 71,858 +25 (plugin-detail)
assets/index-*.js 146,915 -> 146,917 +2
assets/InterfaceListPage-*.js 3,626 -> 3,627 +1

plugin-calendar, plugin-gantt and plugin-dashboard are not in the eager closure at all
0 bytes, confirmed by their absence from the report's file list rather than by assuming "this
package is lazy". The aggregate has 13,396 B of headroom and check:eager-closure passes on both
builds.

⛔ No ceiling was raised, no baseline re-pinned, no gate weakened.

Tests — reproduced first, one file per site

New, all mirroring plugin-grid/src/__tests__/expandFls-7215.test.tsx:

filepre-fixpost-fix
plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx2 failed | 4 passed (6)6 passed
plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx5 failed | 2 passed (7)7 passed

Which pins discriminate, said plainly.DetailView's split is the interesting one and was
predicted before the run: its input filter already delivers the ordinary case, so PIN 1 (denied
lookup among readable authored fields) and PIN 2 (the readable-lookup control) are green in both
directions
and are labelled as such in the file. The two reds are the widening — every authored
field denied, and no authored field list at all — which is what this card actually buys at that
site. A green pin proves nothing on its own; naming the non-discriminating ones is the discipline
RecordDetailView.sectionHeadingsRenderPath-6190.test.tsx records.

Every file carries the live controls, not just the reds: a permitted lookup still expands (a
gate that emptied the expansion would paint raw foreign-key ids — the failure objectui#6453 fixed
on the calendar); master_detail is pinned beside lookup; an undeclared derived column is
untouched and does not take the expansion down with it; an unanswered policy filters nothing;
and the all-denied case yields no $expand rather than a widened one. Each harness waits on a
real recorded call — and, where the schema arrives asynchronously, on the schema-dependent
query specifically — so "the component stopped fetching" times out instead of reading as an empty
expansion.

Gates run locally, on the final commit 96a284fe2

gateresult
vitestplugin-calendar, plugin-gantt, plugin-dashboard, plugin-detail (full packages)293 files, 2565 tests passed
vitest — all 24 RecordDetailView.*.test.tsx24 files, 174 tests passed
vitest — all 19 other app-shell tests naming RecordDetailView19 files, 258 tests passed
type-check — all 5 packages (tsc --noEmit && tsc -p tsconfig.test.json)exit 0, scope 5 of 47
turbo run lint — all 5 packages in fullexit 0 — 0 errors (2885 pre-existing warnings)
check:eager-closure✅ on both builds (figures above)
check:phantom-deps✅ every in-scope import declared by its publishing package
check:control-bytes✅ 6116 tracked text files
check:vi-mock-specifiers / check:vi-mock-inherit✅ / ✅
check:self-import / check:side-effects-array / check:sdui-registration-pins✅ / ✅ / ✅
check-changeset-presence / check-changeset-no-major✅ / ✅

Declared narrowing.app-shell carries 608 test files; the four plugin packages alone took
9m of a ~10m foreground ceiling, so the full app-shell suite was not run locally — the 43 files
above are every test naming RecordDetailView, which is the complete set this diff can reach in
that package. Lint was not narrowed: eslint . ran in full for all five packages, and the
config is not type-aware (no parserOptions.project / projectService), so this diff cannot
move the verdict of any file it does not itself contain. CI runs the whole farm regardless.

Out of scope, filed rather than fixed

The card states buildExpandFields "is called from five more places". Measured on current main,
it is called from eleven production places outside #7229's two. Seven remain ungated after this
PR — kanban, tree, ObjectView, map, gallery, timeline and the metadata-admin PagePreview — and
all seven pass no column list, i.e. the same sharp shape as the three sharpest here. Filed
separately with the per-site measurements; not touched here, because they are outside this card's
declared file surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…hell): FLS-gate `$expand` at the five remaining build sites
objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in
its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own
whitelist) are reached from more places; this closes the five the card names.
Three of them — calendar, gantt and the record page — pass NO column list, so
the helper falls back to every declared relation on the object, denied ones
included: the maximal ask, by default rather than by configuration. DetailView
was INPUT-gated, which is the route #7229 measured as unsound: an emptied column
list reads as "no restriction" and WIDENS the request.
The gate is on the helper's OUTPUT at every site, copied from #7229 rather than
re-derived, so the "checkField answers false for an undeclared key" trap stays
structurally unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
…g build sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-BDsrk2PZ.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.14KB13.02KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.79KB34.62KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.27KB64.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.18KB40.94KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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.74KB2.25KB
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)8.11KB3.32KB
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

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

The out-of-scope finding named at the end of the description is filed as #7429 — the seven buildExpandFields call sites that stay ungated after this PR (kanban, tree, ObjectView, map, gallery, timeline, metadata-admin PagePreview), with the per-site measurements and a triage sizing note. Unassigned; not touched here.

Two notes on the description itself, recorded rather than silently corrected:


Generated by Claude Code

Sign up for freeto 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.

FLS-gate $expand at the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites - #7428

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites
Sep 3, 2026
Merged

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites#7428
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7230

All five sites land. The eager-closure constraint the card carried did not bind — measured, not assumed; figures below.

What an unauthorised principal could ask for before, and cannot after

objectui#7215 / PR #7229 FLS-gated the $expand projection at the two sites in its
scope (ObjectGrid, ListView). The projection is built in more places than that. This
closes the five the card names.

siteshape on mainverdict
plugin-calendar/src/ObjectCalendar.tsx:351buildExpandFields(objectSchema?.fields)no column listgated
plugin-gantt/src/ObjectGantt.tsx:675same, no column listgated
app-shell/src/views/RecordDetailView.tsx:402same, no column listgated
plugin-detail/src/DetailView.tsx:526passes a column list — already INPUT-gatedgated on the OUTPUT
plugin-dashboard/src/ObjectDataTable.tsx:675own whitelist via computeLookupExpandgated

(The card's line numbers were 350 / 674 / 680; the first two are off by one and the
dashboard one by five on current main. Same calls.)

The first three are the sharp ones.buildExpandFields reads an absent column list as
"no column restriction" and falls back to every declared relation on the object, denied
ones included. So a standalone calendar, a gantt, and every record page in the console asked
the server to resolve the object's full relation set by default rather than by
configuration
. $select on a denied lookup asks for a bare foreign key; $expand asks the
server to resolve it and hand back the related record — the larger of the two requests was
the ungated one.

DetailView is not what the card says it is, and the difference matters

The card lists it as "passes a column list, ungated". Measured on main, it is input-gated:
allFields is collected from schema, which is gatedSchema — already FLS-filtered field by
field, and whose own comment names "$expand build" among the uses it means to protect.

So this site is a live instance of exactly the route PR #7229 measured as unsound and rejected.
The consequence is not "a denied lookup slips through in the ordinary case" — it is worse and
narrower:

buildExpandFields reads an empty column list as "no column restriction" and falls back
to every declared relation.

⇒ Filtering the input WIDENS the request precisely where the principal may read least. A
detail view whose authored fields are all denied had its column list gated down to [] and its
$expand widened from the relations it asked for to every relation the object declares. The
same widening is reached with no authored field list at all, where the input filter has nothing
to remove and the expansion is maximal from the start. Both are pinned.

The input filter stays — it is load-bearing for the render half — but it is no longer what
decides the projection.

Reachability and grading — stated narrowly

  • Reachable in an ordinary configuration, on the client-request side. Three of the five need
    no authored column list at all, which is their default shape.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for a
    mechanism read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] on every unreadable key and objectql's expand path writes the resolved
    record back under that same key, so one statement removes the expanded object and the bare
    id alike; the expansion sub-read itself takes the referenced object's full CRUD + RLS + FLS
    treatment (objectstack#7626). Same grading objectui#6898 and $expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 were given, for the same
    reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use.

The fix — #7229's shape copied, not re-derived

The gate is on each helper's OUTPUT at every site. On the three no-column-list sites the
input-side alternative is not merely unsound but unreachable: the call passes undefined, so
there is no input to gate. Gating the output also satisfies the ordering requirement
structurally rather than by convention — buildExpandFields returns a subset of the object's
declared reference-bearing fields, and computeLookupExpand resolves both of its arms
through the object schema's own field map, so every name either gate judges is declared by
construction. The "checkField answers false for an undeclared key" trap is therefore
unreachable and a derived / host-joined column is never judged. Neither helper is changed.

An unanswered policy filters nothing (isLoaded: false is the no-provider default, forever), and
perms is in each site's dependency list, so the projection is rebuilt the moment the answer lands.

One structural note in RecordDetailView, load-bearing rather than cosmetic:usePermissions()
moved ~670 lines up, above the record-load effect. An effect's dependency array is evaluated
during render, so listing perms there while the binding was still declared below would throw
Cannot access 'perms' before initialization — a crash, not a stale value. The later site now
destructures that one value instead of calling the hook again, so the hook order is unchanged in
shape. Same lesson PR #7229 recorded for ListView's memo.

@object-ui/permissions is added as a dependency of plugin-calendar, plugin-gantt and
plugin-dashboard (the other two already had it). check:phantom-deps passes.

⚠️ The eager-closure constraint — measured, and it does not bind

The card required this be measured rather than assumed, and required the app-shell site to be
dropped if it put the framework chunk over its ceiling. It does not. Two full console
builds in the same container, main (bf244f400) versus this branch's final commit 96a284fe2,
read from apps/console/dist/eager-closure.json (exact gzip bytes, not the gate's rounded KB):

chunkbefore (bf244f400)after (96a284fe2)delta
framework523,823 B523,823 B0
vendor-objectstack948,329 B948,329 B0
ui-components396,598 B396,598 B0
aggregate eager closure3,254,545 B3,254,604 B+59 B
eager chunk count48 of 51648 of 5160

framework did not move by one byte, so its 177 B of headroom against the 524,000 B ceiling is
untouched and #7399 is not blocked by this PR. The emitted chunks confirm the mechanism rather
than a source-level guess: all three chunks above kept their content hash
(framework-Cf7M9oOC.js, ui-components-B7CUYYAi.js, vendor-objectstack-CAAM4J8I.js) — they
are byte-identical files.

Why the app-shell site was never a framework risk. The framework group in
apps/console/vite.config.ts is /packages[\\/](core|react|types)[\\/]/app-shell is not a
member and cannot route there. Read off the emitted graph, the whole +59 B is attributed:

assets/RecordDetailView-*.js 35,315 -> 35,346 +31 (its own EAGER chunk)
assets/plugins-views-*.js 71,833 -> 71,858 +25 (plugin-detail)
assets/index-*.js 146,915 -> 146,917 +2
assets/InterfaceListPage-*.js 3,626 -> 3,627 +1

plugin-calendar, plugin-gantt and plugin-dashboard are not in the eager closure at all
0 bytes, confirmed by their absence from the report's file list rather than by assuming "this
package is lazy". The aggregate has 13,396 B of headroom and check:eager-closure passes on both
builds.

⛔ No ceiling was raised, no baseline re-pinned, no gate weakened.

Tests — reproduced first, one file per site

New, all mirroring plugin-grid/src/__tests__/expandFls-7215.test.tsx:

filepre-fixpost-fix
plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx2 failed | 4 passed (6)6 passed
plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx5 failed | 2 passed (7)7 passed

Which pins discriminate, said plainly.DetailView's split is the interesting one and was
predicted before the run: its input filter already delivers the ordinary case, so PIN 1 (denied
lookup among readable authored fields) and PIN 2 (the readable-lookup control) are green in both
directions
and are labelled as such in the file. The two reds are the widening — every authored
field denied, and no authored field list at all — which is what this card actually buys at that
site. A green pin proves nothing on its own; naming the non-discriminating ones is the discipline
RecordDetailView.sectionHeadingsRenderPath-6190.test.tsx records.

Every file carries the live controls, not just the reds: a permitted lookup still expands (a
gate that emptied the expansion would paint raw foreign-key ids — the failure objectui#6453 fixed
on the calendar); master_detail is pinned beside lookup; an undeclared derived column is
untouched and does not take the expansion down with it; an unanswered policy filters nothing;
and the all-denied case yields no $expand rather than a widened one. Each harness waits on a
real recorded call — and, where the schema arrives asynchronously, on the schema-dependent
query specifically — so "the component stopped fetching" times out instead of reading as an empty
expansion.

Gates run locally, on the final commit 96a284fe2

gateresult
vitestplugin-calendar, plugin-gantt, plugin-dashboard, plugin-detail (full packages)293 files, 2565 tests passed
vitest — all 24 RecordDetailView.*.test.tsx24 files, 174 tests passed
vitest — all 19 other app-shell tests naming RecordDetailView19 files, 258 tests passed
type-check — all 5 packages (tsc --noEmit && tsc -p tsconfig.test.json)exit 0, scope 5 of 47
turbo run lint — all 5 packages in fullexit 0 — 0 errors (2885 pre-existing warnings)
check:eager-closure✅ on both builds (figures above)
check:phantom-deps✅ every in-scope import declared by its publishing package
check:control-bytes✅ 6116 tracked text files
check:vi-mock-specifiers / check:vi-mock-inherit✅ / ✅
check:self-import / check:side-effects-array / check:sdui-registration-pins✅ / ✅ / ✅
check-changeset-presence / check-changeset-no-major✅ / ✅

Declared narrowing.app-shell carries 608 test files; the four plugin packages alone took
9m of a ~10m foreground ceiling, so the full app-shell suite was not run locally — the 43 files
above are every test naming RecordDetailView, which is the complete set this diff can reach in
that package. Lint was not narrowed: eslint . ran in full for all five packages, and the
config is not type-aware (no parserOptions.project / projectService), so this diff cannot
move the verdict of any file it does not itself contain. CI runs the whole farm regardless.

Out of scope, filed rather than fixed

The card states buildExpandFields "is called from five more places". Measured on current main,
it is called from eleven production places outside #7229's two. Seven remain ungated after this
PR — kanban, tree, ObjectView, map, gallery, timeline and the metadata-admin PagePreview — and
all seven pass no column list, i.e. the same sharp shape as the three sharpest here. Filed
separately with the per-site measurements; not touched here, because they are outside this card's
declared file surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…hell): FLS-gate `$expand` at the five remaining build sites
objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in
its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own
whitelist) are reached from more places; this closes the five the card names.
Three of them — calendar, gantt and the record page — pass NO column list, so
the helper falls back to every declared relation on the object, denied ones
included: the maximal ask, by default rather than by configuration. DetailView
was INPUT-gated, which is the route #7229 measured as unsound: an emptied column
list reads as "no restriction" and WIDENS the request.
The gate is on the helper's OUTPUT at every site, copied from #7229 rather than
re-derived, so the "checkField answers false for an undeclared key" trap stays
structurally unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
…g build sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-BDsrk2PZ.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.14KB13.02KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.79KB34.62KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.27KB64.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.18KB40.94KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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.74KB2.25KB
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)8.11KB3.32KB
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

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

The out-of-scope finding named at the end of the description is filed as #7429 — the seven buildExpandFields call sites that stay ungated after this PR (kanban, tree, ObjectView, map, gallery, timeline, metadata-admin PagePreview), with the per-site measurements and a triage sizing note. Unassigned; not touched here.

Two notes on the description itself, recorded rather than silently corrected:


Generated by Claude Code

Sign up for freeto 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.

FLS-gate $expand at the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites - #7428

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites
Sep 3, 2026
Merged

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites#7428
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7230

All five sites land. The eager-closure constraint the card carried did not bind — measured, not assumed; figures below.

What an unauthorised principal could ask for before, and cannot after

objectui#7215 / PR #7229 FLS-gated the $expand projection at the two sites in its
scope (ObjectGrid, ListView). The projection is built in more places than that. This
closes the five the card names.

siteshape on mainverdict
plugin-calendar/src/ObjectCalendar.tsx:351buildExpandFields(objectSchema?.fields)no column listgated
plugin-gantt/src/ObjectGantt.tsx:675same, no column listgated
app-shell/src/views/RecordDetailView.tsx:402same, no column listgated
plugin-detail/src/DetailView.tsx:526passes a column list — already INPUT-gatedgated on the OUTPUT
plugin-dashboard/src/ObjectDataTable.tsx:675own whitelist via computeLookupExpandgated

(The card's line numbers were 350 / 674 / 680; the first two are off by one and the
dashboard one by five on current main. Same calls.)

The first three are the sharp ones.buildExpandFields reads an absent column list as
"no column restriction" and falls back to every declared relation on the object, denied
ones included. So a standalone calendar, a gantt, and every record page in the console asked
the server to resolve the object's full relation set by default rather than by
configuration
. $select on a denied lookup asks for a bare foreign key; $expand asks the
server to resolve it and hand back the related record — the larger of the two requests was
the ungated one.

DetailView is not what the card says it is, and the difference matters

The card lists it as "passes a column list, ungated". Measured on main, it is input-gated:
allFields is collected from schema, which is gatedSchema — already FLS-filtered field by
field, and whose own comment names "$expand build" among the uses it means to protect.

So this site is a live instance of exactly the route PR #7229 measured as unsound and rejected.
The consequence is not "a denied lookup slips through in the ordinary case" — it is worse and
narrower:

buildExpandFields reads an empty column list as "no column restriction" and falls back
to every declared relation.

⇒ Filtering the input WIDENS the request precisely where the principal may read least. A
detail view whose authored fields are all denied had its column list gated down to [] and its
$expand widened from the relations it asked for to every relation the object declares. The
same widening is reached with no authored field list at all, where the input filter has nothing
to remove and the expansion is maximal from the start. Both are pinned.

The input filter stays — it is load-bearing for the render half — but it is no longer what
decides the projection.

Reachability and grading — stated narrowly

  • Reachable in an ordinary configuration, on the client-request side. Three of the five need
    no authored column list at all, which is their default shape.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for a
    mechanism read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] on every unreadable key and objectql's expand path writes the resolved
    record back under that same key, so one statement removes the expanded object and the bare
    id alike; the expansion sub-read itself takes the referenced object's full CRUD + RLS + FLS
    treatment (objectstack#7626). Same grading objectui#6898 and $expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 were given, for the same
    reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use.

The fix — #7229's shape copied, not re-derived

The gate is on each helper's OUTPUT at every site. On the three no-column-list sites the
input-side alternative is not merely unsound but unreachable: the call passes undefined, so
there is no input to gate. Gating the output also satisfies the ordering requirement
structurally rather than by convention — buildExpandFields returns a subset of the object's
declared reference-bearing fields, and computeLookupExpand resolves both of its arms
through the object schema's own field map, so every name either gate judges is declared by
construction. The "checkField answers false for an undeclared key" trap is therefore
unreachable and a derived / host-joined column is never judged. Neither helper is changed.

An unanswered policy filters nothing (isLoaded: false is the no-provider default, forever), and
perms is in each site's dependency list, so the projection is rebuilt the moment the answer lands.

One structural note in RecordDetailView, load-bearing rather than cosmetic:usePermissions()
moved ~670 lines up, above the record-load effect. An effect's dependency array is evaluated
during render, so listing perms there while the binding was still declared below would throw
Cannot access 'perms' before initialization — a crash, not a stale value. The later site now
destructures that one value instead of calling the hook again, so the hook order is unchanged in
shape. Same lesson PR #7229 recorded for ListView's memo.

@object-ui/permissions is added as a dependency of plugin-calendar, plugin-gantt and
plugin-dashboard (the other two already had it). check:phantom-deps passes.

⚠️ The eager-closure constraint — measured, and it does not bind

The card required this be measured rather than assumed, and required the app-shell site to be
dropped if it put the framework chunk over its ceiling. It does not. Two full console
builds in the same container, main (bf244f400) versus this branch's final commit 96a284fe2,
read from apps/console/dist/eager-closure.json (exact gzip bytes, not the gate's rounded KB):

chunkbefore (bf244f400)after (96a284fe2)delta
framework523,823 B523,823 B0
vendor-objectstack948,329 B948,329 B0
ui-components396,598 B396,598 B0
aggregate eager closure3,254,545 B3,254,604 B+59 B
eager chunk count48 of 51648 of 5160

framework did not move by one byte, so its 177 B of headroom against the 524,000 B ceiling is
untouched and #7399 is not blocked by this PR. The emitted chunks confirm the mechanism rather
than a source-level guess: all three chunks above kept their content hash
(framework-Cf7M9oOC.js, ui-components-B7CUYYAi.js, vendor-objectstack-CAAM4J8I.js) — they
are byte-identical files.

Why the app-shell site was never a framework risk. The framework group in
apps/console/vite.config.ts is /packages[\\/](core|react|types)[\\/]/app-shell is not a
member and cannot route there. Read off the emitted graph, the whole +59 B is attributed:

assets/RecordDetailView-*.js 35,315 -> 35,346 +31 (its own EAGER chunk)
assets/plugins-views-*.js 71,833 -> 71,858 +25 (plugin-detail)
assets/index-*.js 146,915 -> 146,917 +2
assets/InterfaceListPage-*.js 3,626 -> 3,627 +1

plugin-calendar, plugin-gantt and plugin-dashboard are not in the eager closure at all
0 bytes, confirmed by their absence from the report's file list rather than by assuming "this
package is lazy". The aggregate has 13,396 B of headroom and check:eager-closure passes on both
builds.

⛔ No ceiling was raised, no baseline re-pinned, no gate weakened.

Tests — reproduced first, one file per site

New, all mirroring plugin-grid/src/__tests__/expandFls-7215.test.tsx:

filepre-fixpost-fix
plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx2 failed | 4 passed (6)6 passed
plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx5 failed | 2 passed (7)7 passed

Which pins discriminate, said plainly.DetailView's split is the interesting one and was
predicted before the run: its input filter already delivers the ordinary case, so PIN 1 (denied
lookup among readable authored fields) and PIN 2 (the readable-lookup control) are green in both
directions
and are labelled as such in the file. The two reds are the widening — every authored
field denied, and no authored field list at all — which is what this card actually buys at that
site. A green pin proves nothing on its own; naming the non-discriminating ones is the discipline
RecordDetailView.sectionHeadingsRenderPath-6190.test.tsx records.

Every file carries the live controls, not just the reds: a permitted lookup still expands (a
gate that emptied the expansion would paint raw foreign-key ids — the failure objectui#6453 fixed
on the calendar); master_detail is pinned beside lookup; an undeclared derived column is
untouched and does not take the expansion down with it; an unanswered policy filters nothing;
and the all-denied case yields no $expand rather than a widened one. Each harness waits on a
real recorded call — and, where the schema arrives asynchronously, on the schema-dependent
query specifically — so "the component stopped fetching" times out instead of reading as an empty
expansion.

Gates run locally, on the final commit 96a284fe2

gateresult
vitestplugin-calendar, plugin-gantt, plugin-dashboard, plugin-detail (full packages)293 files, 2565 tests passed
vitest — all 24 RecordDetailView.*.test.tsx24 files, 174 tests passed
vitest — all 19 other app-shell tests naming RecordDetailView19 files, 258 tests passed
type-check — all 5 packages (tsc --noEmit && tsc -p tsconfig.test.json)exit 0, scope 5 of 47
turbo run lint — all 5 packages in fullexit 0 — 0 errors (2885 pre-existing warnings)
check:eager-closure✅ on both builds (figures above)
check:phantom-deps✅ every in-scope import declared by its publishing package
check:control-bytes✅ 6116 tracked text files
check:vi-mock-specifiers / check:vi-mock-inherit✅ / ✅
check:self-import / check:side-effects-array / check:sdui-registration-pins✅ / ✅ / ✅
check-changeset-presence / check-changeset-no-major✅ / ✅

Declared narrowing.app-shell carries 608 test files; the four plugin packages alone took
9m of a ~10m foreground ceiling, so the full app-shell suite was not run locally — the 43 files
above are every test naming RecordDetailView, which is the complete set this diff can reach in
that package. Lint was not narrowed: eslint . ran in full for all five packages, and the
config is not type-aware (no parserOptions.project / projectService), so this diff cannot
move the verdict of any file it does not itself contain. CI runs the whole farm regardless.

Out of scope, filed rather than fixed

The card states buildExpandFields "is called from five more places". Measured on current main,
it is called from eleven production places outside #7229's two. Seven remain ungated after this
PR — kanban, tree, ObjectView, map, gallery, timeline and the metadata-admin PagePreview — and
all seven pass no column list, i.e. the same sharp shape as the three sharpest here. Filed
separately with the per-site measurements; not touched here, because they are outside this card's
declared file surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…hell): FLS-gate `$expand` at the five remaining build sites
objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in
its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own
whitelist) are reached from more places; this closes the five the card names.
Three of them — calendar, gantt and the record page — pass NO column list, so
the helper falls back to every declared relation on the object, denied ones
included: the maximal ask, by default rather than by configuration. DetailView
was INPUT-gated, which is the route #7229 measured as unsound: an emptied column
list reads as "no restriction" and WIDENS the request.
The gate is on the helper's OUTPUT at every site, copied from #7229 rather than
re-derived, so the "checkField answers false for an undeclared key" trap stays
structurally unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
…g build sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-BDsrk2PZ.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.14KB13.02KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.79KB34.62KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.27KB64.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.18KB40.94KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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.74KB2.25KB
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)8.11KB3.32KB
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

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

The out-of-scope finding named at the end of the description is filed as #7429 — the seven buildExpandFields call sites that stay ungated after this PR (kanban, tree, ObjectView, map, gallery, timeline, metadata-admin PagePreview), with the per-site measurements and a triage sizing note. Unassigned; not touched here.

Two notes on the description itself, recorded rather than silently corrected:


Generated by Claude Code

Sign up for freeto 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.

FLS-gate $expand at the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites - #7428

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites
Sep 3, 2026
Merged

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites#7428
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7230

All five sites land. The eager-closure constraint the card carried did not bind — measured, not assumed; figures below.

What an unauthorised principal could ask for before, and cannot after

objectui#7215 / PR #7229 FLS-gated the $expand projection at the two sites in its
scope (ObjectGrid, ListView). The projection is built in more places than that. This
closes the five the card names.

siteshape on mainverdict
plugin-calendar/src/ObjectCalendar.tsx:351buildExpandFields(objectSchema?.fields)no column listgated
plugin-gantt/src/ObjectGantt.tsx:675same, no column listgated
app-shell/src/views/RecordDetailView.tsx:402same, no column listgated
plugin-detail/src/DetailView.tsx:526passes a column list — already INPUT-gatedgated on the OUTPUT
plugin-dashboard/src/ObjectDataTable.tsx:675own whitelist via computeLookupExpandgated

(The card's line numbers were 350 / 674 / 680; the first two are off by one and the
dashboard one by five on current main. Same calls.)

The first three are the sharp ones.buildExpandFields reads an absent column list as
"no column restriction" and falls back to every declared relation on the object, denied
ones included. So a standalone calendar, a gantt, and every record page in the console asked
the server to resolve the object's full relation set by default rather than by
configuration
. $select on a denied lookup asks for a bare foreign key; $expand asks the
server to resolve it and hand back the related record — the larger of the two requests was
the ungated one.

DetailView is not what the card says it is, and the difference matters

The card lists it as "passes a column list, ungated". Measured on main, it is input-gated:
allFields is collected from schema, which is gatedSchema — already FLS-filtered field by
field, and whose own comment names "$expand build" among the uses it means to protect.

So this site is a live instance of exactly the route PR #7229 measured as unsound and rejected.
The consequence is not "a denied lookup slips through in the ordinary case" — it is worse and
narrower:

buildExpandFields reads an empty column list as "no column restriction" and falls back
to every declared relation.

⇒ Filtering the input WIDENS the request precisely where the principal may read least. A
detail view whose authored fields are all denied had its column list gated down to [] and its
$expand widened from the relations it asked for to every relation the object declares. The
same widening is reached with no authored field list at all, where the input filter has nothing
to remove and the expansion is maximal from the start. Both are pinned.

The input filter stays — it is load-bearing for the render half — but it is no longer what
decides the projection.

Reachability and grading — stated narrowly

  • Reachable in an ordinary configuration, on the client-request side. Three of the five need
    no authored column list at all, which is their default shape.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for a
    mechanism read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] on every unreadable key and objectql's expand path writes the resolved
    record back under that same key, so one statement removes the expanded object and the bare
    id alike; the expansion sub-read itself takes the referenced object's full CRUD + RLS + FLS
    treatment (objectstack#7626). Same grading objectui#6898 and $expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 were given, for the same
    reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use.

The fix — #7229's shape copied, not re-derived

The gate is on each helper's OUTPUT at every site. On the three no-column-list sites the
input-side alternative is not merely unsound but unreachable: the call passes undefined, so
there is no input to gate. Gating the output also satisfies the ordering requirement
structurally rather than by convention — buildExpandFields returns a subset of the object's
declared reference-bearing fields, and computeLookupExpand resolves both of its arms
through the object schema's own field map, so every name either gate judges is declared by
construction. The "checkField answers false for an undeclared key" trap is therefore
unreachable and a derived / host-joined column is never judged. Neither helper is changed.

An unanswered policy filters nothing (isLoaded: false is the no-provider default, forever), and
perms is in each site's dependency list, so the projection is rebuilt the moment the answer lands.

One structural note in RecordDetailView, load-bearing rather than cosmetic:usePermissions()
moved ~670 lines up, above the record-load effect. An effect's dependency array is evaluated
during render, so listing perms there while the binding was still declared below would throw
Cannot access 'perms' before initialization — a crash, not a stale value. The later site now
destructures that one value instead of calling the hook again, so the hook order is unchanged in
shape. Same lesson PR #7229 recorded for ListView's memo.

@object-ui/permissions is added as a dependency of plugin-calendar, plugin-gantt and
plugin-dashboard (the other two already had it). check:phantom-deps passes.

⚠️ The eager-closure constraint — measured, and it does not bind

The card required this be measured rather than assumed, and required the app-shell site to be
dropped if it put the framework chunk over its ceiling. It does not. Two full console
builds in the same container, main (bf244f400) versus this branch's final commit 96a284fe2,
read from apps/console/dist/eager-closure.json (exact gzip bytes, not the gate's rounded KB):

chunkbefore (bf244f400)after (96a284fe2)delta
framework523,823 B523,823 B0
vendor-objectstack948,329 B948,329 B0
ui-components396,598 B396,598 B0
aggregate eager closure3,254,545 B3,254,604 B+59 B
eager chunk count48 of 51648 of 5160

framework did not move by one byte, so its 177 B of headroom against the 524,000 B ceiling is
untouched and #7399 is not blocked by this PR. The emitted chunks confirm the mechanism rather
than a source-level guess: all three chunks above kept their content hash
(framework-Cf7M9oOC.js, ui-components-B7CUYYAi.js, vendor-objectstack-CAAM4J8I.js) — they
are byte-identical files.

Why the app-shell site was never a framework risk. The framework group in
apps/console/vite.config.ts is /packages[\\/](core|react|types)[\\/]/app-shell is not a
member and cannot route there. Read off the emitted graph, the whole +59 B is attributed:

assets/RecordDetailView-*.js 35,315 -> 35,346 +31 (its own EAGER chunk)
assets/plugins-views-*.js 71,833 -> 71,858 +25 (plugin-detail)
assets/index-*.js 146,915 -> 146,917 +2
assets/InterfaceListPage-*.js 3,626 -> 3,627 +1

plugin-calendar, plugin-gantt and plugin-dashboard are not in the eager closure at all
0 bytes, confirmed by their absence from the report's file list rather than by assuming "this
package is lazy". The aggregate has 13,396 B of headroom and check:eager-closure passes on both
builds.

⛔ No ceiling was raised, no baseline re-pinned, no gate weakened.

Tests — reproduced first, one file per site

New, all mirroring plugin-grid/src/__tests__/expandFls-7215.test.tsx:

filepre-fixpost-fix
plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx2 failed | 4 passed (6)6 passed
plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx5 failed | 2 passed (7)7 passed

Which pins discriminate, said plainly.DetailView's split is the interesting one and was
predicted before the run: its input filter already delivers the ordinary case, so PIN 1 (denied
lookup among readable authored fields) and PIN 2 (the readable-lookup control) are green in both
directions
and are labelled as such in the file. The two reds are the widening — every authored
field denied, and no authored field list at all — which is what this card actually buys at that
site. A green pin proves nothing on its own; naming the non-discriminating ones is the discipline
RecordDetailView.sectionHeadingsRenderPath-6190.test.tsx records.

Every file carries the live controls, not just the reds: a permitted lookup still expands (a
gate that emptied the expansion would paint raw foreign-key ids — the failure objectui#6453 fixed
on the calendar); master_detail is pinned beside lookup; an undeclared derived column is
untouched and does not take the expansion down with it; an unanswered policy filters nothing;
and the all-denied case yields no $expand rather than a widened one. Each harness waits on a
real recorded call — and, where the schema arrives asynchronously, on the schema-dependent
query specifically — so "the component stopped fetching" times out instead of reading as an empty
expansion.

Gates run locally, on the final commit 96a284fe2

gateresult
vitestplugin-calendar, plugin-gantt, plugin-dashboard, plugin-detail (full packages)293 files, 2565 tests passed
vitest — all 24 RecordDetailView.*.test.tsx24 files, 174 tests passed
vitest — all 19 other app-shell tests naming RecordDetailView19 files, 258 tests passed
type-check — all 5 packages (tsc --noEmit && tsc -p tsconfig.test.json)exit 0, scope 5 of 47
turbo run lint — all 5 packages in fullexit 0 — 0 errors (2885 pre-existing warnings)
check:eager-closure✅ on both builds (figures above)
check:phantom-deps✅ every in-scope import declared by its publishing package
check:control-bytes✅ 6116 tracked text files
check:vi-mock-specifiers / check:vi-mock-inherit✅ / ✅
check:self-import / check:side-effects-array / check:sdui-registration-pins✅ / ✅ / ✅
check-changeset-presence / check-changeset-no-major✅ / ✅

Declared narrowing.app-shell carries 608 test files; the four plugin packages alone took
9m of a ~10m foreground ceiling, so the full app-shell suite was not run locally — the 43 files
above are every test naming RecordDetailView, which is the complete set this diff can reach in
that package. Lint was not narrowed: eslint . ran in full for all five packages, and the
config is not type-aware (no parserOptions.project / projectService), so this diff cannot
move the verdict of any file it does not itself contain. CI runs the whole farm regardless.

Out of scope, filed rather than fixed

The card states buildExpandFields "is called from five more places". Measured on current main,
it is called from eleven production places outside #7229's two. Seven remain ungated after this
PR — kanban, tree, ObjectView, map, gallery, timeline and the metadata-admin PagePreview — and
all seven pass no column list, i.e. the same sharp shape as the three sharpest here. Filed
separately with the per-site measurements; not touched here, because they are outside this card's
declared file surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…hell): FLS-gate `$expand` at the five remaining build sites
objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in
its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own
whitelist) are reached from more places; this closes the five the card names.
Three of them — calendar, gantt and the record page — pass NO column list, so
the helper falls back to every declared relation on the object, denied ones
included: the maximal ask, by default rather than by configuration. DetailView
was INPUT-gated, which is the route #7229 measured as unsound: an emptied column
list reads as "no restriction" and WIDENS the request.
The gate is on the helper's OUTPUT at every site, copied from #7229 rather than
re-derived, so the "checkField answers false for an undeclared key" trap stays
structurally unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
…g build sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-BDsrk2PZ.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.14KB13.02KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.79KB34.62KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.27KB64.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.18KB40.94KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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.74KB2.25KB
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)8.11KB3.32KB
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

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

The out-of-scope finding named at the end of the description is filed as #7429 — the seven buildExpandFields call sites that stay ungated after this PR (kanban, tree, ObjectView, map, gallery, timeline, metadata-admin PagePreview), with the per-site measurements and a triage sizing note. Unassigned; not touched here.

Two notes on the description itself, recorded rather than silently corrected:


Generated by Claude Code

Sign up for freeto 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.

FLS-gate $expand at the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites - #7428

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites
Sep 3, 2026
Merged

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites#7428
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7230

All five sites land. The eager-closure constraint the card carried did not bind — measured, not assumed; figures below.

What an unauthorised principal could ask for before, and cannot after

objectui#7215 / PR #7229 FLS-gated the $expand projection at the two sites in its
scope (ObjectGrid, ListView). The projection is built in more places than that. This
closes the five the card names.

siteshape on mainverdict
plugin-calendar/src/ObjectCalendar.tsx:351buildExpandFields(objectSchema?.fields)no column listgated
plugin-gantt/src/ObjectGantt.tsx:675same, no column listgated
app-shell/src/views/RecordDetailView.tsx:402same, no column listgated
plugin-detail/src/DetailView.tsx:526passes a column list — already INPUT-gatedgated on the OUTPUT
plugin-dashboard/src/ObjectDataTable.tsx:675own whitelist via computeLookupExpandgated

(The card's line numbers were 350 / 674 / 680; the first two are off by one and the
dashboard one by five on current main. Same calls.)

The first three are the sharp ones.buildExpandFields reads an absent column list as
"no column restriction" and falls back to every declared relation on the object, denied
ones included. So a standalone calendar, a gantt, and every record page in the console asked
the server to resolve the object's full relation set by default rather than by
configuration
. $select on a denied lookup asks for a bare foreign key; $expand asks the
server to resolve it and hand back the related record — the larger of the two requests was
the ungated one.

DetailView is not what the card says it is, and the difference matters

The card lists it as "passes a column list, ungated". Measured on main, it is input-gated:
allFields is collected from schema, which is gatedSchema — already FLS-filtered field by
field, and whose own comment names "$expand build" among the uses it means to protect.

So this site is a live instance of exactly the route PR #7229 measured as unsound and rejected.
The consequence is not "a denied lookup slips through in the ordinary case" — it is worse and
narrower:

buildExpandFields reads an empty column list as "no column restriction" and falls back
to every declared relation.

⇒ Filtering the input WIDENS the request precisely where the principal may read least. A
detail view whose authored fields are all denied had its column list gated down to [] and its
$expand widened from the relations it asked for to every relation the object declares. The
same widening is reached with no authored field list at all, where the input filter has nothing
to remove and the expansion is maximal from the start. Both are pinned.

The input filter stays — it is load-bearing for the render half — but it is no longer what
decides the projection.

Reachability and grading — stated narrowly

  • Reachable in an ordinary configuration, on the client-request side. Three of the five need
    no authored column list at all, which is their default shape.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for a
    mechanism read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] on every unreadable key and objectql's expand path writes the resolved
    record back under that same key, so one statement removes the expanded object and the bare
    id alike; the expansion sub-read itself takes the referenced object's full CRUD + RLS + FLS
    treatment (objectstack#7626). Same grading objectui#6898 and $expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 were given, for the same
    reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use.

The fix — #7229's shape copied, not re-derived

The gate is on each helper's OUTPUT at every site. On the three no-column-list sites the
input-side alternative is not merely unsound but unreachable: the call passes undefined, so
there is no input to gate. Gating the output also satisfies the ordering requirement
structurally rather than by convention — buildExpandFields returns a subset of the object's
declared reference-bearing fields, and computeLookupExpand resolves both of its arms
through the object schema's own field map, so every name either gate judges is declared by
construction. The "checkField answers false for an undeclared key" trap is therefore
unreachable and a derived / host-joined column is never judged. Neither helper is changed.

An unanswered policy filters nothing (isLoaded: false is the no-provider default, forever), and
perms is in each site's dependency list, so the projection is rebuilt the moment the answer lands.

One structural note in RecordDetailView, load-bearing rather than cosmetic:usePermissions()
moved ~670 lines up, above the record-load effect. An effect's dependency array is evaluated
during render, so listing perms there while the binding was still declared below would throw
Cannot access 'perms' before initialization — a crash, not a stale value. The later site now
destructures that one value instead of calling the hook again, so the hook order is unchanged in
shape. Same lesson PR #7229 recorded for ListView's memo.

@object-ui/permissions is added as a dependency of plugin-calendar, plugin-gantt and
plugin-dashboard (the other two already had it). check:phantom-deps passes.

⚠️ The eager-closure constraint — measured, and it does not bind

The card required this be measured rather than assumed, and required the app-shell site to be
dropped if it put the framework chunk over its ceiling. It does not. Two full console
builds in the same container, main (bf244f400) versus this branch's final commit 96a284fe2,
read from apps/console/dist/eager-closure.json (exact gzip bytes, not the gate's rounded KB):

chunkbefore (bf244f400)after (96a284fe2)delta
framework523,823 B523,823 B0
vendor-objectstack948,329 B948,329 B0
ui-components396,598 B396,598 B0
aggregate eager closure3,254,545 B3,254,604 B+59 B
eager chunk count48 of 51648 of 5160

framework did not move by one byte, so its 177 B of headroom against the 524,000 B ceiling is
untouched and #7399 is not blocked by this PR. The emitted chunks confirm the mechanism rather
than a source-level guess: all three chunks above kept their content hash
(framework-Cf7M9oOC.js, ui-components-B7CUYYAi.js, vendor-objectstack-CAAM4J8I.js) — they
are byte-identical files.

Why the app-shell site was never a framework risk. The framework group in
apps/console/vite.config.ts is /packages[\\/](core|react|types)[\\/]/app-shell is not a
member and cannot route there. Read off the emitted graph, the whole +59 B is attributed:

assets/RecordDetailView-*.js 35,315 -> 35,346 +31 (its own EAGER chunk)
assets/plugins-views-*.js 71,833 -> 71,858 +25 (plugin-detail)
assets/index-*.js 146,915 -> 146,917 +2
assets/InterfaceListPage-*.js 3,626 -> 3,627 +1

plugin-calendar, plugin-gantt and plugin-dashboard are not in the eager closure at all
0 bytes, confirmed by their absence from the report's file list rather than by assuming "this
package is lazy". The aggregate has 13,396 B of headroom and check:eager-closure passes on both
builds.

⛔ No ceiling was raised, no baseline re-pinned, no gate weakened.

Tests — reproduced first, one file per site

New, all mirroring plugin-grid/src/__tests__/expandFls-7215.test.tsx:

filepre-fixpost-fix
plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx2 failed | 4 passed (6)6 passed
plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx5 failed | 2 passed (7)7 passed

Which pins discriminate, said plainly.DetailView's split is the interesting one and was
predicted before the run: its input filter already delivers the ordinary case, so PIN 1 (denied
lookup among readable authored fields) and PIN 2 (the readable-lookup control) are green in both
directions
and are labelled as such in the file. The two reds are the widening — every authored
field denied, and no authored field list at all — which is what this card actually buys at that
site. A green pin proves nothing on its own; naming the non-discriminating ones is the discipline
RecordDetailView.sectionHeadingsRenderPath-6190.test.tsx records.

Every file carries the live controls, not just the reds: a permitted lookup still expands (a
gate that emptied the expansion would paint raw foreign-key ids — the failure objectui#6453 fixed
on the calendar); master_detail is pinned beside lookup; an undeclared derived column is
untouched and does not take the expansion down with it; an unanswered policy filters nothing;
and the all-denied case yields no $expand rather than a widened one. Each harness waits on a
real recorded call — and, where the schema arrives asynchronously, on the schema-dependent
query specifically — so "the component stopped fetching" times out instead of reading as an empty
expansion.

Gates run locally, on the final commit 96a284fe2

gateresult
vitestplugin-calendar, plugin-gantt, plugin-dashboard, plugin-detail (full packages)293 files, 2565 tests passed
vitest — all 24 RecordDetailView.*.test.tsx24 files, 174 tests passed
vitest — all 19 other app-shell tests naming RecordDetailView19 files, 258 tests passed
type-check — all 5 packages (tsc --noEmit && tsc -p tsconfig.test.json)exit 0, scope 5 of 47
turbo run lint — all 5 packages in fullexit 0 — 0 errors (2885 pre-existing warnings)
check:eager-closure✅ on both builds (figures above)
check:phantom-deps✅ every in-scope import declared by its publishing package
check:control-bytes✅ 6116 tracked text files
check:vi-mock-specifiers / check:vi-mock-inherit✅ / ✅
check:self-import / check:side-effects-array / check:sdui-registration-pins✅ / ✅ / ✅
check-changeset-presence / check-changeset-no-major✅ / ✅

Declared narrowing.app-shell carries 608 test files; the four plugin packages alone took
9m of a ~10m foreground ceiling, so the full app-shell suite was not run locally — the 43 files
above are every test naming RecordDetailView, which is the complete set this diff can reach in
that package. Lint was not narrowed: eslint . ran in full for all five packages, and the
config is not type-aware (no parserOptions.project / projectService), so this diff cannot
move the verdict of any file it does not itself contain. CI runs the whole farm regardless.

Out of scope, filed rather than fixed

The card states buildExpandFields "is called from five more places". Measured on current main,
it is called from eleven production places outside #7229's two. Seven remain ungated after this
PR — kanban, tree, ObjectView, map, gallery, timeline and the metadata-admin PagePreview — and
all seven pass no column list, i.e. the same sharp shape as the three sharpest here. Filed
separately with the per-site measurements; not touched here, because they are outside this card's
declared file surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…hell): FLS-gate `$expand` at the five remaining build sites
objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in
its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own
whitelist) are reached from more places; this closes the five the card names.
Three of them — calendar, gantt and the record page — pass NO column list, so
the helper falls back to every declared relation on the object, denied ones
included: the maximal ask, by default rather than by configuration. DetailView
was INPUT-gated, which is the route #7229 measured as unsound: an emptied column
list reads as "no restriction" and WIDENS the request.
The gate is on the helper's OUTPUT at every site, copied from #7229 rather than
re-derived, so the "checkField answers false for an undeclared key" trap stays
structurally unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
…g build sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-BDsrk2PZ.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.14KB13.02KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.79KB34.62KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.27KB64.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.18KB40.94KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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.74KB2.25KB
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)8.11KB3.32KB
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

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

The out-of-scope finding named at the end of the description is filed as #7429 — the seven buildExpandFields call sites that stay ungated after this PR (kanban, tree, ObjectView, map, gallery, timeline, metadata-admin PagePreview), with the per-site measurements and a triage sizing note. Unassigned; not touched here.

Two notes on the description itself, recorded rather than silently corrected:


Generated by Claude Code

Sign up for freeto 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.

FLS-gate $expand at the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites - #7428

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites
Sep 3, 2026
Merged

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites#7428
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7230

All five sites land. The eager-closure constraint the card carried did not bind — measured, not assumed; figures below.

What an unauthorised principal could ask for before, and cannot after

objectui#7215 / PR #7229 FLS-gated the $expand projection at the two sites in its
scope (ObjectGrid, ListView). The projection is built in more places than that. This
closes the five the card names.

siteshape on mainverdict
plugin-calendar/src/ObjectCalendar.tsx:351buildExpandFields(objectSchema?.fields)no column listgated
plugin-gantt/src/ObjectGantt.tsx:675same, no column listgated
app-shell/src/views/RecordDetailView.tsx:402same, no column listgated
plugin-detail/src/DetailView.tsx:526passes a column list — already INPUT-gatedgated on the OUTPUT
plugin-dashboard/src/ObjectDataTable.tsx:675own whitelist via computeLookupExpandgated

(The card's line numbers were 350 / 674 / 680; the first two are off by one and the
dashboard one by five on current main. Same calls.)

The first three are the sharp ones.buildExpandFields reads an absent column list as
"no column restriction" and falls back to every declared relation on the object, denied
ones included. So a standalone calendar, a gantt, and every record page in the console asked
the server to resolve the object's full relation set by default rather than by
configuration
. $select on a denied lookup asks for a bare foreign key; $expand asks the
server to resolve it and hand back the related record — the larger of the two requests was
the ungated one.

DetailView is not what the card says it is, and the difference matters

The card lists it as "passes a column list, ungated". Measured on main, it is input-gated:
allFields is collected from schema, which is gatedSchema — already FLS-filtered field by
field, and whose own comment names "$expand build" among the uses it means to protect.

So this site is a live instance of exactly the route PR #7229 measured as unsound and rejected.
The consequence is not "a denied lookup slips through in the ordinary case" — it is worse and
narrower:

buildExpandFields reads an empty column list as "no column restriction" and falls back
to every declared relation.

⇒ Filtering the input WIDENS the request precisely where the principal may read least. A
detail view whose authored fields are all denied had its column list gated down to [] and its
$expand widened from the relations it asked for to every relation the object declares. The
same widening is reached with no authored field list at all, where the input filter has nothing
to remove and the expansion is maximal from the start. Both are pinned.

The input filter stays — it is load-bearing for the render half — but it is no longer what
decides the projection.

Reachability and grading — stated narrowly

  • Reachable in an ordinary configuration, on the client-request side. Three of the five need
    no authored column list at all, which is their default shape.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for a
    mechanism read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] on every unreadable key and objectql's expand path writes the resolved
    record back under that same key, so one statement removes the expanded object and the bare
    id alike; the expansion sub-read itself takes the referenced object's full CRUD + RLS + FLS
    treatment (objectstack#7626). Same grading objectui#6898 and $expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 were given, for the same
    reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use.

The fix — #7229's shape copied, not re-derived

The gate is on each helper's OUTPUT at every site. On the three no-column-list sites the
input-side alternative is not merely unsound but unreachable: the call passes undefined, so
there is no input to gate. Gating the output also satisfies the ordering requirement
structurally rather than by convention — buildExpandFields returns a subset of the object's
declared reference-bearing fields, and computeLookupExpand resolves both of its arms
through the object schema's own field map, so every name either gate judges is declared by
construction. The "checkField answers false for an undeclared key" trap is therefore
unreachable and a derived / host-joined column is never judged. Neither helper is changed.

An unanswered policy filters nothing (isLoaded: false is the no-provider default, forever), and
perms is in each site's dependency list, so the projection is rebuilt the moment the answer lands.

One structural note in RecordDetailView, load-bearing rather than cosmetic:usePermissions()
moved ~670 lines up, above the record-load effect. An effect's dependency array is evaluated
during render, so listing perms there while the binding was still declared below would throw
Cannot access 'perms' before initialization — a crash, not a stale value. The later site now
destructures that one value instead of calling the hook again, so the hook order is unchanged in
shape. Same lesson PR #7229 recorded for ListView's memo.

@object-ui/permissions is added as a dependency of plugin-calendar, plugin-gantt and
plugin-dashboard (the other two already had it). check:phantom-deps passes.

⚠️ The eager-closure constraint — measured, and it does not bind

The card required this be measured rather than assumed, and required the app-shell site to be
dropped if it put the framework chunk over its ceiling. It does not. Two full console
builds in the same container, main (bf244f400) versus this branch's final commit 96a284fe2,
read from apps/console/dist/eager-closure.json (exact gzip bytes, not the gate's rounded KB):

chunkbefore (bf244f400)after (96a284fe2)delta
framework523,823 B523,823 B0
vendor-objectstack948,329 B948,329 B0
ui-components396,598 B396,598 B0
aggregate eager closure3,254,545 B3,254,604 B+59 B
eager chunk count48 of 51648 of 5160

framework did not move by one byte, so its 177 B of headroom against the 524,000 B ceiling is
untouched and #7399 is not blocked by this PR. The emitted chunks confirm the mechanism rather
than a source-level guess: all three chunks above kept their content hash
(framework-Cf7M9oOC.js, ui-components-B7CUYYAi.js, vendor-objectstack-CAAM4J8I.js) — they
are byte-identical files.

Why the app-shell site was never a framework risk. The framework group in
apps/console/vite.config.ts is /packages[\\/](core|react|types)[\\/]/app-shell is not a
member and cannot route there. Read off the emitted graph, the whole +59 B is attributed:

assets/RecordDetailView-*.js 35,315 -> 35,346 +31 (its own EAGER chunk)
assets/plugins-views-*.js 71,833 -> 71,858 +25 (plugin-detail)
assets/index-*.js 146,915 -> 146,917 +2
assets/InterfaceListPage-*.js 3,626 -> 3,627 +1

plugin-calendar, plugin-gantt and plugin-dashboard are not in the eager closure at all
0 bytes, confirmed by their absence from the report's file list rather than by assuming "this
package is lazy". The aggregate has 13,396 B of headroom and check:eager-closure passes on both
builds.

⛔ No ceiling was raised, no baseline re-pinned, no gate weakened.

Tests — reproduced first, one file per site

New, all mirroring plugin-grid/src/__tests__/expandFls-7215.test.tsx:

filepre-fixpost-fix
plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx2 failed | 4 passed (6)6 passed
plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx5 failed | 2 passed (7)7 passed

Which pins discriminate, said plainly.DetailView's split is the interesting one and was
predicted before the run: its input filter already delivers the ordinary case, so PIN 1 (denied
lookup among readable authored fields) and PIN 2 (the readable-lookup control) are green in both
directions
and are labelled as such in the file. The two reds are the widening — every authored
field denied, and no authored field list at all — which is what this card actually buys at that
site. A green pin proves nothing on its own; naming the non-discriminating ones is the discipline
RecordDetailView.sectionHeadingsRenderPath-6190.test.tsx records.

Every file carries the live controls, not just the reds: a permitted lookup still expands (a
gate that emptied the expansion would paint raw foreign-key ids — the failure objectui#6453 fixed
on the calendar); master_detail is pinned beside lookup; an undeclared derived column is
untouched and does not take the expansion down with it; an unanswered policy filters nothing;
and the all-denied case yields no $expand rather than a widened one. Each harness waits on a
real recorded call — and, where the schema arrives asynchronously, on the schema-dependent
query specifically — so "the component stopped fetching" times out instead of reading as an empty
expansion.

Gates run locally, on the final commit 96a284fe2

gateresult
vitestplugin-calendar, plugin-gantt, plugin-dashboard, plugin-detail (full packages)293 files, 2565 tests passed
vitest — all 24 RecordDetailView.*.test.tsx24 files, 174 tests passed
vitest — all 19 other app-shell tests naming RecordDetailView19 files, 258 tests passed
type-check — all 5 packages (tsc --noEmit && tsc -p tsconfig.test.json)exit 0, scope 5 of 47
turbo run lint — all 5 packages in fullexit 0 — 0 errors (2885 pre-existing warnings)
check:eager-closure✅ on both builds (figures above)
check:phantom-deps✅ every in-scope import declared by its publishing package
check:control-bytes✅ 6116 tracked text files
check:vi-mock-specifiers / check:vi-mock-inherit✅ / ✅
check:self-import / check:side-effects-array / check:sdui-registration-pins✅ / ✅ / ✅
check-changeset-presence / check-changeset-no-major✅ / ✅

Declared narrowing.app-shell carries 608 test files; the four plugin packages alone took
9m of a ~10m foreground ceiling, so the full app-shell suite was not run locally — the 43 files
above are every test naming RecordDetailView, which is the complete set this diff can reach in
that package. Lint was not narrowed: eslint . ran in full for all five packages, and the
config is not type-aware (no parserOptions.project / projectService), so this diff cannot
move the verdict of any file it does not itself contain. CI runs the whole farm regardless.

Out of scope, filed rather than fixed

The card states buildExpandFields "is called from five more places". Measured on current main,
it is called from eleven production places outside #7229's two. Seven remain ungated after this
PR — kanban, tree, ObjectView, map, gallery, timeline and the metadata-admin PagePreview — and
all seven pass no column list, i.e. the same sharp shape as the three sharpest here. Filed
separately with the per-site measurements; not touched here, because they are outside this card's
declared file surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…hell): FLS-gate `$expand` at the five remaining build sites
objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in
its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own
whitelist) are reached from more places; this closes the five the card names.
Three of them — calendar, gantt and the record page — pass NO column list, so
the helper falls back to every declared relation on the object, denied ones
included: the maximal ask, by default rather than by configuration. DetailView
was INPUT-gated, which is the route #7229 measured as unsound: an emptied column
list reads as "no restriction" and WIDENS the request.
The gate is on the helper's OUTPUT at every site, copied from #7229 rather than
re-derived, so the "checkField answers false for an undeclared key" trap stays
structurally unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
…g build sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-BDsrk2PZ.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.14KB13.02KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.79KB34.62KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.27KB64.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.18KB40.94KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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.74KB2.25KB
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)8.11KB3.32KB
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

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

The out-of-scope finding named at the end of the description is filed as #7429 — the seven buildExpandFields call sites that stay ungated after this PR (kanban, tree, ObjectView, map, gallery, timeline, metadata-admin PagePreview), with the per-site measurements and a triage sizing note. Unassigned; not touched here.

Two notes on the description itself, recorded rather than silently corrected:


Generated by Claude Code

Sign up for freeto 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.

FLS-gate $expand at the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites - #7428

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites
Sep 3, 2026
Merged

fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate $expand at the five remaining build sites#7428
os-project-manager merged 2 commits into
mainfrom
claude/issue-7230-fls-gate-expand-five-sites

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7230

All five sites land. The eager-closure constraint the card carried did not bind — measured, not assumed; figures below.

What an unauthorised principal could ask for before, and cannot after

objectui#7215 / PR #7229 FLS-gated the $expand projection at the two sites in its
scope (ObjectGrid, ListView). The projection is built in more places than that. This
closes the five the card names.

siteshape on mainverdict
plugin-calendar/src/ObjectCalendar.tsx:351buildExpandFields(objectSchema?.fields)no column listgated
plugin-gantt/src/ObjectGantt.tsx:675same, no column listgated
app-shell/src/views/RecordDetailView.tsx:402same, no column listgated
plugin-detail/src/DetailView.tsx:526passes a column list — already INPUT-gatedgated on the OUTPUT
plugin-dashboard/src/ObjectDataTable.tsx:675own whitelist via computeLookupExpandgated

(The card's line numbers were 350 / 674 / 680; the first two are off by one and the
dashboard one by five on current main. Same calls.)

The first three are the sharp ones.buildExpandFields reads an absent column list as
"no column restriction" and falls back to every declared relation on the object, denied
ones included. So a standalone calendar, a gantt, and every record page in the console asked
the server to resolve the object's full relation set by default rather than by
configuration
. $select on a denied lookup asks for a bare foreign key; $expand asks the
server to resolve it and hand back the related record — the larger of the two requests was
the ungated one.

DetailView is not what the card says it is, and the difference matters

The card lists it as "passes a column list, ungated". Measured on main, it is input-gated:
allFields is collected from schema, which is gatedSchema — already FLS-filtered field by
field, and whose own comment names "$expand build" among the uses it means to protect.

So this site is a live instance of exactly the route PR #7229 measured as unsound and rejected.
The consequence is not "a denied lookup slips through in the ordinary case" — it is worse and
narrower:

buildExpandFields reads an empty column list as "no column restriction" and falls back
to every declared relation.

⇒ Filtering the input WIDENS the request precisely where the principal may read least. A
detail view whose authored fields are all denied had its column list gated down to [] and its
$expand widened from the relations it asked for to every relation the object declares. The
same widening is reached with no authored field list at all, where the input filter has nothing
to remove and the expansion is maximal from the start. Both are pinned.

The input filter stays — it is load-bearing for the render half — but it is no longer what
decides the projection.

Reachability and grading — stated narrowly

  • Reachable in an ordinary configuration, on the client-request side. Three of the five need
    no authored column list at all, which is their default shape.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for a
    mechanism read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] on every unreadable key and objectql's expand path writes the resolved
    record back under that same key, so one statement removes the expanded object and the bare
    id alike; the expansion sub-read itself takes the referenced object's full CRUD + RLS + FLS
    treatment (objectstack#7626). Same grading objectui#6898 and $expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 were given, for the same
    reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use.

The fix — #7229's shape copied, not re-derived

The gate is on each helper's OUTPUT at every site. On the three no-column-list sites the
input-side alternative is not merely unsound but unreachable: the call passes undefined, so
there is no input to gate. Gating the output also satisfies the ordering requirement
structurally rather than by convention — buildExpandFields returns a subset of the object's
declared reference-bearing fields, and computeLookupExpand resolves both of its arms
through the object schema's own field map, so every name either gate judges is declared by
construction. The "checkField answers false for an undeclared key" trap is therefore
unreachable and a derived / host-joined column is never judged. Neither helper is changed.

An unanswered policy filters nothing (isLoaded: false is the no-provider default, forever), and
perms is in each site's dependency list, so the projection is rebuilt the moment the answer lands.

One structural note in RecordDetailView, load-bearing rather than cosmetic:usePermissions()
moved ~670 lines up, above the record-load effect. An effect's dependency array is evaluated
during render, so listing perms there while the binding was still declared below would throw
Cannot access 'perms' before initialization — a crash, not a stale value. The later site now
destructures that one value instead of calling the hook again, so the hook order is unchanged in
shape. Same lesson PR #7229 recorded for ListView's memo.

@object-ui/permissions is added as a dependency of plugin-calendar, plugin-gantt and
plugin-dashboard (the other two already had it). check:phantom-deps passes.

⚠️ The eager-closure constraint — measured, and it does not bind

The card required this be measured rather than assumed, and required the app-shell site to be
dropped if it put the framework chunk over its ceiling. It does not. Two full console
builds in the same container, main (bf244f400) versus this branch's final commit 96a284fe2,
read from apps/console/dist/eager-closure.json (exact gzip bytes, not the gate's rounded KB):

chunkbefore (bf244f400)after (96a284fe2)delta
framework523,823 B523,823 B0
vendor-objectstack948,329 B948,329 B0
ui-components396,598 B396,598 B0
aggregate eager closure3,254,545 B3,254,604 B+59 B
eager chunk count48 of 51648 of 5160

framework did not move by one byte, so its 177 B of headroom against the 524,000 B ceiling is
untouched and #7399 is not blocked by this PR. The emitted chunks confirm the mechanism rather
than a source-level guess: all three chunks above kept their content hash
(framework-Cf7M9oOC.js, ui-components-B7CUYYAi.js, vendor-objectstack-CAAM4J8I.js) — they
are byte-identical files.

Why the app-shell site was never a framework risk. The framework group in
apps/console/vite.config.ts is /packages[\\/](core|react|types)[\\/]/app-shell is not a
member and cannot route there. Read off the emitted graph, the whole +59 B is attributed:

assets/RecordDetailView-*.js 35,315 -> 35,346 +31 (its own EAGER chunk)
assets/plugins-views-*.js 71,833 -> 71,858 +25 (plugin-detail)
assets/index-*.js 146,915 -> 146,917 +2
assets/InterfaceListPage-*.js 3,626 -> 3,627 +1

plugin-calendar, plugin-gantt and plugin-dashboard are not in the eager closure at all
0 bytes, confirmed by their absence from the report's file list rather than by assuming "this
package is lazy". The aggregate has 13,396 B of headroom and check:eager-closure passes on both
builds.

⛔ No ceiling was raised, no baseline re-pinned, no gate weakened.

Tests — reproduced first, one file per site

New, all mirroring plugin-grid/src/__tests__/expandFls-7215.test.tsx:

filepre-fixpost-fix
plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx4 failed | 2 passed (6)6 passed
plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx2 failed | 4 passed (6)6 passed
plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx5 failed | 2 passed (7)7 passed

Which pins discriminate, said plainly.DetailView's split is the interesting one and was
predicted before the run: its input filter already delivers the ordinary case, so PIN 1 (denied
lookup among readable authored fields) and PIN 2 (the readable-lookup control) are green in both
directions
and are labelled as such in the file. The two reds are the widening — every authored
field denied, and no authored field list at all — which is what this card actually buys at that
site. A green pin proves nothing on its own; naming the non-discriminating ones is the discipline
RecordDetailView.sectionHeadingsRenderPath-6190.test.tsx records.

Every file carries the live controls, not just the reds: a permitted lookup still expands (a
gate that emptied the expansion would paint raw foreign-key ids — the failure objectui#6453 fixed
on the calendar); master_detail is pinned beside lookup; an undeclared derived column is
untouched and does not take the expansion down with it; an unanswered policy filters nothing;
and the all-denied case yields no $expand rather than a widened one. Each harness waits on a
real recorded call — and, where the schema arrives asynchronously, on the schema-dependent
query specifically — so "the component stopped fetching" times out instead of reading as an empty
expansion.

Gates run locally, on the final commit 96a284fe2

gateresult
vitestplugin-calendar, plugin-gantt, plugin-dashboard, plugin-detail (full packages)293 files, 2565 tests passed
vitest — all 24 RecordDetailView.*.test.tsx24 files, 174 tests passed
vitest — all 19 other app-shell tests naming RecordDetailView19 files, 258 tests passed
type-check — all 5 packages (tsc --noEmit && tsc -p tsconfig.test.json)exit 0, scope 5 of 47
turbo run lint — all 5 packages in fullexit 0 — 0 errors (2885 pre-existing warnings)
check:eager-closure✅ on both builds (figures above)
check:phantom-deps✅ every in-scope import declared by its publishing package
check:control-bytes✅ 6116 tracked text files
check:vi-mock-specifiers / check:vi-mock-inherit✅ / ✅
check:self-import / check:side-effects-array / check:sdui-registration-pins✅ / ✅ / ✅
check-changeset-presence / check-changeset-no-major✅ / ✅

Declared narrowing.app-shell carries 608 test files; the four plugin packages alone took
9m of a ~10m foreground ceiling, so the full app-shell suite was not run locally — the 43 files
above are every test naming RecordDetailView, which is the complete set this diff can reach in
that package. Lint was not narrowed: eslint . ran in full for all five packages, and the
config is not type-aware (no parserOptions.project / projectService), so this diff cannot
move the verdict of any file it does not itself contain. CI runs the whole farm regardless.

Out of scope, filed rather than fixed

The card states buildExpandFields "is called from five more places". Measured on current main,
it is called from eleven production places outside #7229's two. Seven remain ungated after this
PR — kanban, tree, ObjectView, map, gallery, timeline and the metadata-admin PagePreview — and
all seven pass no column list, i.e. the same sharp shape as the three sharpest here. Filed
separately with the per-site measurements; not touched here, because they are outside this card's
declared file surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…hell): FLS-gate `$expand` at the five remaining build sites
objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in
its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own
whitelist) are reached from more places; this closes the five the card names.
Three of them — calendar, gantt and the record page — pass NO column list, so
the helper falls back to every declared relation on the object, denied ones
included: the maximal ask, by default rather than by configuration. DetailView
was INPUT-gated, which is the route #7229 measured as unsound: an emptied column
list reads as "no restriction" and WIDENS the request.
The gate is on the helper's OUTPUT at every site, copied from #7229 rather than
re-derived, so the "checkField answers false for an undeclared key" trap stays
structurally unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
…g build sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-BDsrk2PZ.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)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.14KB13.02KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.79KB34.62KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.27KB64.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.18KB40.94KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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.74KB2.25KB
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)8.11KB3.32KB
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

@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

The out-of-scope finding named at the end of the description is filed as #7429 — the seven buildExpandFields call sites that stay ungated after this PR (kanban, tree, ObjectView, map, gallery, timeline, metadata-admin PagePreview), with the per-site measurements and a triage sizing note. Unassigned; not touched here.

Two notes on the description itself, recorded rather than silently corrected:


Generated by Claude Code

Sign up for freeto 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.

FLS-gate $expand at the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all

2 participants

@os-project-manager@claude