diff --git a/.changeset/6723-inline-data-fls.md b/.changeset/6723-inline-data-fls.md
new file mode 100644
index 000000000..248b793ee
--- /dev/null
+++ b/.changeset/6723-inline-data-fls.md
@@ -0,0 +1,54 @@
+---
+'@object-ui/plugin-grid': patch
+---
+
+`ObjectGrid` re-applies field-level security on its inline-data column path too,
+so whether an object-bound grid re-checks FLS no longer depends on who fetched
+the rows (objectui#6723, maintainer ruling 2026-08-29).
+
+`generateColumns()` re-applied FLS at exactly one place — the object-schema
+path. The inline-data path, taken when a host hands rows down as `data` **and**
+the author declared a `fields` projection, had no equivalent check. Both paths
+serve object-bound grids, so the same object with the same authored projection
+did or did not go through the field gate purely according to provenance:
+
+| rows from | `fields` declared | path taken | FLS re-applied |
+| --- | --- | --- | --- |
+| grid fetches | no | object-schema | yes |
+| grid fetches | yes | object-schema | yes |
+| host passes `data` | no | object-schema (since objectui#6677) | yes |
+| host passes `data` | yes | inline-data | **no, until now** |
+
+The inline-data path now filters each column through
+`perms.checkField(objectName, fieldName, 'read')` when `perms.isLoaded &&
+schema.objectName`, the same gate and the same deferral condition the
+object-schema path has always used.
+
+⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
+rather than an optimisation.** Host-joined and derived keys pass through
+untouched, because keeping them is this path's whole reason to exist — the
+object-schema path drops them outright (`if (!field) return;`). A field policy
+that enumerates readable fields answers "no" for a key it has never heard of, so
+judging derived keys would silently drop them, which is the failure the issue's
+own analysis warned about. Declaration is read with `hasOwnProperty`, so an
+inherited name (`constructor`) is not mistaken for a declared field.
+
+**Defence in depth, not a reachable exploit through the shipped hosts.**
+`ListView` — the dominant host — already filters its own `effectiveFields`
+through this same gate before forwarding, and that redundancy is the point: the
+invariant must not rest on every future host having read the docs. The exposure
+this closes is a direct
+`` composition, or a
+future host that forwards an authored projection unfiltered.
+
+Deliberately unchanged, and refused by name in the ruling: the two paths' other
+differences stay as they are — the schema path's `resolveFieldLabel` (i18n) vs
+the inline path's local humanisation, and the schema path's drop of names the
+object does not declare. Converging those is a separate decision.
+
+Pinned in `packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx` (a
+readable declared field renders; an unreadable declared field does not, even
+with host data for it; a derived key is unaffected; plus the perms-not-loaded,
+no-`objectName` and schema-in-flight boundaries and a case through the real
+`PermissionProvider`) and, as a measured no-op on the `ListView` path, in
+`packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx`.
diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx
index 0988ddca0..2b55805e4 100644
--- a/packages/plugin-grid/src/ObjectGrid.tsx
+++ b/packages/plugin-grid/src/ObjectGrid.tsx
@@ -2299,7 +2299,42 @@ export const ObjectGrid: React.FC = ({
if (hasInlineData && !rowKeysWouldOutrankSchemaPolicy) {
const inlineData = dataConfig?.provider === 'value' ? dataConfig.items as any[] : [];
if (inlineData.length > 0) {
- const fieldsToShow = schemaFields || Object.keys(inlineData[0]);
+ // FLS on the inline-data path (objectui#6723 — maintainer ruling
+ // 2026-08-29: the NARROW defence-in-depth fix, not a convergence).
+ //
+ // The object-schema path below re-applies field-level security to the
+ // columns it derives; this path did not. So whether an object-bound
+ // grid re-checked FLS depended on WHO FETCHED THE ROWS: same object,
+ // same authored projection, rows the grid fetched went through the
+ // gate and rows a host handed down did not. That is the asymmetry, and
+ // a security invariant may not be decided by the data's provenance.
+ //
+ // ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION. Only keys the
+ // OBJECT DECLARES are judged; everything else passes through
+ // untouched. A host may legitimately join or derive columns
+ // (`computed_score`, a flattened `account.name`), and keeping those is
+ // this path's whole reason to exist — the object-schema path drops
+ // them outright (`if (!field) return;`). Judging an undeclared key
+ // would silently drop derived columns, which is the failure
+ // objectui#6723's own analysis warned about and which the ruling
+ // refuses by name. `checkField` answers `false` for a field the
+ // policy has never heard of, so asking it about a derived key is not
+ // a stricter reading of the same rule — it is a different, wrong one.
+ //
+ // Redundant through `ListView`, which filters its own `effectiveFields`
+ // through this same gate before forwarding (its source says so), and
+ // that redundancy IS the point: the invariant must not rest on every
+ // future host having read the docs. Pinned as a byte-for-byte no-op on
+ // that path in `inlineDataFls-6723.test.tsx`.
+ const fieldsToShow = (schemaFields || Object.keys(inlineData[0])).filter((fieldName) => {
+ if (!perms?.isLoaded || !schema.objectName) return true;
+ // Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
+ // `hasOwnProperty` rather than a truthiness read so an inherited
+ // name (`constructor`, `toString`) cannot be mistaken for a declared
+ // field and dropped.
+ if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
+ return perms.checkField(schema.objectName, fieldName, 'read');
+ });
return fieldsToShow.map((fieldName) => {
const fieldDef = objectSchema?.fields?.[fieldName];
// Annotated for the same reason as paths A and B (objectui#6004).
diff --git a/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx
new file mode 100644
index 000000000..0612ede75
--- /dev/null
+++ b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx
@@ -0,0 +1,353 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#6723 — field-level security on `generateColumns()`'s INLINE-DATA
+ * path, and the limit that makes it safe.
+ *
+ * ## The defect
+ *
+ * `generateColumns()` re-applied FLS at exactly one place: the object-schema
+ * path (`if (perms?.isLoaded && schema.objectName && !perms.checkField(...))
+ * return;`). The inline-data path — taken when a host hands rows down as `data`
+ * AND the author declared a `fields` projection — had no equivalent check. Both
+ * paths serve object-bound grids, so the same object with the same authored
+ * projection did or did not re-check FLS purely according to WHO FETCHED THE
+ * ROWS:
+ *
+ * grid fetches, `fields` declared -> object-schema path -> FLS re-applied
+ * host passes `data`, `fields` -> inline-data path -> FLS SKIPPED
+ *
+ * A security invariant may not be decided by the data's provenance. Maintainer
+ * ruling 2026-08-29: take the NARROW defence-in-depth fix.
+ *
+ * ## The limit is the point, not an optimisation
+ *
+ * Only keys the OBJECT DECLARES are judged. Host-joined and derived keys pass
+ * through untouched, because keeping them is this path's whole reason to exist
+ * (the object-schema path drops them outright: `if (!field) return;`). Judging
+ * them would silently drop derived columns — the failure the issue's own
+ * analysis warned about, and the one the ruling refuses by name.
+ *
+ * ## Why the stub `checkField` is an ALLOWLIST
+ *
+ * `PermissionProvider` (role-based) answers `true` for a field no policy
+ * mentions, so under it a derived key survives whether or not the guard judges
+ * it — the limit above would be untestable, and PIN 3 would be green in both
+ * worlds for the wrong reason. The stub therefore models the shape a server
+ * that ENUMERATES readable fields produces: deny anything not listed. That is
+ * the only policy shape under which the limit is load-bearing, so it is the one
+ * the limit is pinned against. The real provider still gets a case of its own
+ * (WIRING below), so nothing here rests solely on an imitation.
+ *
+ * ## ABLATION — guard removed (this file restored to d06059f24), this file only
+ *
+ * 2 red / 6 green. Both reds are the same assertion asked twice, once of the
+ * stub and once of the real provider — which is what says the fix is wired to
+ * the actual gate and not only to the shape of a double:
+ *
+ * x PIN 2 — a declared field the principal cannot read is NOT rendered
+ * -> AssertionError: expected [ 'Opportunity Name', 'Salary' ] to deeply
+ * equal [ 'Opportunity Name' ]
+ * x WIRING — the REAL PermissionProvider gate drops the denied column
+ * -> AssertionError: expected [ 'Opportunity Name', 'Salary' ] to deeply
+ * equal [ 'Opportunity Name' ]
+ *
+ * PIN 1, PIN 3, PIN 3b and the three controls DID NOT MOVE, which is the half
+ * that says the guard is narrow: they are the boundaries it must not cross, not
+ * restatements of it. Restoring the guard returns the file to 8 green.
+ *
+ * `vitest.config.mts` aliases every `@object-ui/*` specifier to that package's
+ * `src`, and this file imports `../ObjectGrid` relatively, so no build step
+ * stands between the edit and the run — the ablation reads source directly, and
+ * the mutation was confirmed on disk by blob hash before the run.
+ */
+import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import React from 'react';
+
+/**
+ * Stable stub identity: `ObjectGrid` carries `perms` in `useCallback` /
+ * `useMemo` dependency arrays, so a fresh object per call would churn those
+ * memos on every render. Same hoisted-state shape as
+ * `inlineEditPermissionGate.test.tsx`.
+ */
+const { permsStub, state } = vi.hoisted(() => {
+ const state: {
+ /** Has `/me/permissions` answered yet? `false` = defer, filter nothing. */
+ isLoaded: boolean;
+ /** Fields this principal may READ. Anything absent is denied. */
+ readable: string[];
+ /** Bypass the stub and run the REAL provider-backed hook instead. */
+ useRealProvider: boolean;
+ } = { isLoaded: true, readable: [], useRealProvider: false };
+ return {
+ state,
+ permsStub: {
+ get isLoaded() { return state.isLoaded; },
+ checkField: (_object: string, field: string, action: string) =>
+ action === 'read' ? state.readable.includes(field) : true,
+ check: () => ({ allowed: true }),
+ getFieldPermissions: () => [],
+ getRowFilter: () => undefined,
+ getObjectApiOperations: () => undefined,
+ roles: [],
+ userId: null,
+ systemPermissions: undefined,
+ hasCapabilities: () => true,
+ can: () => true,
+ cannot: () => false,
+ },
+ };
+});
+
+// The real module stays reachable so the WIRING case below exercises the ACTUAL
+// `PermissionProvider` gate rather than a hand-written imitation of it. The
+// real hook is invoked on every render so hook order is stable whichever branch
+// is returned.
+vi.mock('@object-ui/permissions', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ usePermissions: () => {
+ const real = actual.usePermissions();
+ return state.useRealProvider ? real : (permsStub as any);
+ },
+ };
+});
+
+import { ObjectGrid } from '../ObjectGrid';
+import { registerAllFields } from '@object-ui/fields';
+import { ActionProvider } from '@object-ui/react';
+import { PermissionProvider } from '@object-ui/permissions';
+import type { ObjectPermissionConfig, RoleDefinition } from '@object-ui/types';
+
+registerAllFields();
+
+beforeAll(() => {
+ if (!Element.prototype.hasPointerCapture) {
+ Element.prototype.hasPointerCapture = vi.fn(() => false) as any;
+ }
+ if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = vi.fn() as any;
+ }
+});
+
+beforeEach(() => {
+ state.isLoaded = true;
+ state.readable = [];
+ state.useRealProvider = false;
+});
+afterEach(() => cleanup());
+
+const OBJECT = 'opportunity';
+
+/**
+ * `salary` is the field under test: DECLARED by the object and denied to this
+ * principal. `computed_score` is deliberately NOT declared — it is the
+ * host-joined / derived key the limit protects.
+ */
+const OPPORTUNITY_SCHEMA = {
+ name: OBJECT,
+ label: 'Opportunity',
+ fields: {
+ name: { type: 'text', label: 'Opportunity Name' },
+ amount: { type: 'currency', label: 'Amount', currency: 'USD' },
+ salary: { type: 'number', label: 'Salary' },
+ },
+};
+
+/**
+ * Rows exactly as a host hands them down — INCLUDING a payload for the denied
+ * field. That is what makes PIN 2 about the grid and not about the fetch: the
+ * value is right there in memory and must still not reach the screen.
+ */
+const HOST_ROWS = [
+ { id: 'o-1', name: 'Acme expansion', amount: 42000, salary: 120000, computed_score: 'A+' },
+];
+
+function makeDataSource(overrides: Record = {}) {
+ return {
+ // A host owns the fetch, so the grid must never call this.
+ find: vi.fn(async () => ({ data: [], total: 0 })),
+ getObjectSchema: vi.fn(async () => OPPORTUNITY_SCHEMA),
+ ...overrides,
+ } as any;
+}
+
+/**
+ * The DATA columns' header labels, in render order. Two kinds of furniture are
+ * dropped: cells with no header text (selection checkbox, row-action kebab) and
+ * the row-index column, whose header is a literal `#`.
+ */
+function dataHeaders(container: HTMLElement): string[] {
+ return Array.from(container.querySelectorAll('thead th'))
+ .map((th) => (th.textContent ?? '').trim())
+ .filter((text) => text.length > 0 && text !== '#');
+}
+
+function renderHostFedGrid(
+ schemaOverrides: Record = {},
+ dataSource?: any,
+ wrap?: (el: React.ReactElement) => React.ReactElement,
+) {
+ const ds = dataSource ?? makeDataSource();
+ const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaOverrides };
+ const inner = (
+
+
+
+ );
+ const utils = render(wrap ? wrap(inner) : inner);
+ return { ...utils, ds };
+}
+
+describe('ObjectGrid — FLS on the inline-data path (#6723)', () => {
+ it('PIN 1 — a declared field the principal CAN read renders its column', async () => {
+ state.readable = ['name', 'amount'];
+ const { container, ds } = renderHostFedGrid({ fields: ['name', 'amount'] });
+
+ await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT));
+ await waitFor(() =>
+ expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Amount']));
+
+ // The positive half: a guard that dropped everything would also satisfy
+ // PIN 2, so the readable columns have to be pinned present.
+ expect(screen.getByText('Acme expansion')).toBeInTheDocument();
+ expect(ds.find).not.toHaveBeenCalled();
+ });
+
+ it('PIN 2 — a declared field the principal CANNOT read does not render, even with host data for it', async () => {
+ state.readable = ['name'];
+ const { container, ds } = renderHostFedGrid({ fields: ['name', 'salary'] });
+
+ await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT));
+ await waitFor(() =>
+ expect(dataHeaders(container)).toEqual(['Opportunity Name']));
+
+ // The absence half, twice over: not the header, and not the VALUE the host
+ // already put in the payload. `120000` is `salary` on the only row.
+ expect(dataHeaders(container)).not.toContain('Salary');
+ expect(screen.queryByText('120000')).toBeNull();
+ });
+
+ it('PIN 3 — a key the object does not declare is unaffected by the gate', async () => {
+ // `computed_score` is not an object field, and the allowlist does not name
+ // it, so a guard that judged undeclared keys would drop it here. That drop
+ // is the failure mode the ruling refuses by name.
+ state.readable = ['name'];
+ const { container, ds } = renderHostFedGrid({ fields: ['name', 'computed_score'] });
+
+ await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT));
+ await waitFor(() =>
+ expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Computed score']));
+ expect(screen.getByText('A+')).toBeInTheDocument();
+ });
+
+ it('PIN 3b — an INHERITED name is not mistaken for a declared field', async () => {
+ // `objectSchema.fields.constructor` resolves through the prototype chain,
+ // so a truthiness read (`fields?.[name]`) would call it declared, ask the
+ // allowlist about it, and drop a derived column named `constructor`. The
+ // guard reads `hasOwnProperty` for exactly this reason.
+ state.readable = ['name'];
+ const rows = [{ ...HOST_ROWS[0], constructor: 'derived-value' }];
+ const ds = makeDataSource();
+ const { container } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT));
+ await waitFor(() => expect(dataHeaders(container)).toContain('Constructor'));
+ });
+
+ /* ------------------------------------------------------------------ *
+ * Boundaries — green in BOTH worlds. Controls, not restatements. *
+ * ------------------------------------------------------------------ */
+
+ it('CONTROL — permissions not loaded yet: nothing is filtered', async () => {
+ // `/me/permissions` has not answered. The existing object-schema gate defers
+ // in exactly this case (`perms?.isLoaded &&`), and so must this one: a grid
+ // that blanked its columns while perms were in flight would be a worse
+ // defect than the one being fixed.
+ state.isLoaded = false;
+ state.readable = [];
+ const { container, ds } = renderHostFedGrid({ fields: ['name', 'salary'] });
+
+ await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT));
+ await waitFor(() =>
+ expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Salary']));
+ });
+
+ it('CONTROL — no `objectName`: a pure inline-data grid is untouched', async () => {
+ // No object behind the data means no policy to apply. `checkField` needs an
+ // object to answer about, and the existing gate reads `schema.objectName`
+ // for the same reason.
+ state.readable = [];
+ const { container } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument());
+ expect(dataHeaders(container)).toEqual(['Name', 'Salary']);
+ });
+
+ it('CONTROL — object schema still in flight: the row-key fallback is untouched', async () => {
+ // `objectSchema` is `null`, so NOTHING is declared yet and every key is a
+ // derived key as far as this gate can tell. First paint keeps the row keys
+ // rather than blanking (the boundary #6677 pinned), and the gate must not
+ // move that.
+ state.readable = [];
+ const pending = new Promise(() => { /* never resolves */ });
+ const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) });
+ const { container } = renderHostFedGrid({}, ds);
+
+ await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument());
+ expect(dataHeaders(container)).toEqual(['Id', 'Name', 'Amount', 'Salary', 'Computed score']);
+ });
+
+ it('WIRING — the REAL PermissionProvider gate drops the denied declared column', async () => {
+ // Everything above runs against the stub. This case runs the actual
+ // `@object-ui/permissions` provider end to end, so the fix is pinned to the
+ // real `checkField` and not only to the shape of a double.
+ state.useRealProvider = true;
+ const roles: RoleDefinition[] = [{ name: 'restricted', label: 'Restricted' }];
+ const permissions: ObjectPermissionConfig[] = [
+ {
+ object: OBJECT,
+ roles: {
+ restricted: {
+ actions: ['read'],
+ fieldPermissions: [{ field: 'salary', read: false, write: false }],
+ },
+ },
+ },
+ ];
+
+ const { container, ds } = renderHostFedGrid({ fields: ['name', 'salary'] }, undefined, (el) => (
+
+ {el}
+
+ ));
+
+ await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT));
+ await waitFor(() => expect(dataHeaders(container)).toEqual(['Opportunity Name']));
+ expect(screen.queryByText('120000')).toBeNull();
+ });
+});
diff --git a/packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx b/packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx
new file mode 100644
index 000000000..9cd3043de
--- /dev/null
+++ b/packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx
@@ -0,0 +1,185 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#6723, PIN 4 — the grid's new inline-data FLS gate is a NO-OP through
+ * `ListView`, and that is measured here rather than assumed.
+ *
+ * ## Why this pin exists at all
+ *
+ * `ListView` filters its own `effectiveFields` through `checkField` before
+ * forwarding, so the grid-side gate #6723 adds is redundant on this path. The
+ * ruling keeps both ("redundancy is depth"), which makes "redundant" a claim
+ * about behaviour that has to hold: adding a second gate must not change one
+ * pixel of what this host renders.
+ *
+ * ## Why it cannot fire here — the mechanism, pinned by the assertions
+ *
+ * `ListView` reaches the grid two ways, and neither one arrives at the
+ * inline-data branch carrying a declared-but-denied key:
+ *
+ * - AUTHORED columns: it forwards `columns: effectiveFields` (already
+ * FLS-filtered) alongside the rows, and `generateColumns()` returns from
+ * the `normalizeColumns(schemaColumns)` branch before the inline-data
+ * branch is reached. Pinned below by exact header equality with the
+ * filtered projection.
+ * - UNAUTHORED: it forwards `fields: undefined, columns: undefined`
+ * (objectui#6598), so `rowKeysWouldOutrankSchemaPolicy` is true once the
+ * schema has loaded and the grid falls through to the OBJECT-SCHEMA path —
+ * the one that has always re-applied FLS. Pinned below by the absence of
+ * `Id`: `id` is `hidden: true`, which the object-schema policy drops and
+ * the inline-data path (row keys) would keep. That absence is what makes
+ * this case a mechanism assertion and not just a header count.
+ *
+ * ## Reading this file under ablation
+ *
+ * Every case here is green with the #6723 guard present AND with it removed —
+ * that identity IS pin 4. The CONTROL case is what keeps the two denials above
+ * from being vacuous: with no permission policy mounted, `Amount` renders in
+ * both shapes, so the assertions measure the field gate rather than a render
+ * that never produced the column.
+ *
+ * The grid here is the REAL `@object-ui/plugin-grid` renderer, not a stub: this
+ * file is listed in `heavyDomTests` (vitest.config.mts), whose setup imports
+ * plugin-grid for its side-effect registration — the same route
+ * `ListView.crossPageSelectAll.test.tsx` and the two #6598 files take. A stub
+ * grid cannot observe a no-op inside the grid.
+ */
+import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { I18nProvider, SchemaRendererProvider } from '@object-ui/react';
+import { PermissionProvider } from '@object-ui/permissions';
+import { ListView } from '../ListView';
+import type { ListViewSchema, ObjectPermissionConfig, RoleDefinition } from '@object-ui/types';
+
+const OBJECT = 'opportunity';
+
+beforeAll(() => {
+ if (!Element.prototype.hasPointerCapture) {
+ Element.prototype.hasPointerCapture = vi.fn(() => false) as any;
+ }
+ if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = vi.fn() as any;
+ }
+});
+beforeEach(() => { vi.clearAllMocks(); });
+afterEach(() => { cleanup(); });
+
+/**
+ * `id` is `hidden: true` on purpose: the object-schema default-columns policy
+ * drops it and the inline-data row-key derivation keeps it, so its presence or
+ * absence tells the two grid paths apart from the outside.
+ */
+const OPPORTUNITY_FIELDS = {
+ id: { type: 'text', label: 'Id', hidden: true },
+ name: { type: 'text', label: 'Opportunity Name' },
+ amount: { type: 'currency', label: 'Amount' },
+};
+
+function makeDataSource() {
+ return {
+ find: vi.fn(async () => ({
+ data: [{ id: 'o-1', name: 'Acme expansion', amount: 1000 }],
+ total: 1,
+ hasMore: false,
+ })),
+ findOne: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ getObjectSchema: async (name: string) => ({ name, label: 'Opportunity', fields: OPPORTUNITY_FIELDS }),
+ } as any;
+}
+
+const listSchema = (over: Record = {}): ListViewSchema =>
+ ({ type: 'list-view', objectName: OBJECT, ...over }) as unknown as ListViewSchema;
+
+/** Denies `amount` — a field the object DECLARES — to the current principal. */
+const ROLES: RoleDefinition[] = [{ name: 'restricted', label: 'Restricted' }];
+const DENY_AMOUNT: ObjectPermissionConfig[] = [
+ {
+ object: OBJECT,
+ roles: {
+ restricted: {
+ actions: ['read'],
+ fieldPermissions: [{ field: 'amount', read: false, write: false }],
+ },
+ },
+ },
+];
+
+function renderList(schema: ListViewSchema, permissions?: ObjectPermissionConfig[]) {
+ const ds = makeDataSource();
+ const inner = (
+
+
+
+
+
+ );
+ const utils = render(
+ permissions
+ ? (
+
+ {inner}
+
+ )
+ : inner,
+ );
+ return { ...utils, ds };
+}
+
+/** Data column headers in render order — furniture and the `#` index dropped. */
+function dataHeaders(container: HTMLElement): string[] {
+ return Array.from(container.querySelectorAll('thead th'))
+ .map((th) => (th.textContent ?? '').trim())
+ .filter((text) => text.length > 0 && text !== '#');
+}
+
+describe('ListView-hosted grid — the #6723 inline-data FLS gate is a no-op here', () => {
+ it('AUTHORED columns: the host pre-filtered, and the grid renders exactly what it was handed', async () => {
+ const { container } = renderList(listSchema({ columns: ['name', 'amount'] }), DENY_AMOUNT);
+
+ await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument());
+ // `effectiveFields` already dropped `amount`, so the grid never sees it.
+ await waitFor(() => expect(dataHeaders(container)).toEqual(['Opportunity Name']));
+ expect(screen.queryByText('1,000.00')).toBeNull();
+ });
+
+ it('UNAUTHORED: the grid takes the OBJECT-SCHEMA path, whose own FLS gate is the one that answers', async () => {
+ const { container } = renderList(listSchema(), DENY_AMOUNT);
+
+ await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument());
+ const headers = await waitFor(() => {
+ const h = dataHeaders(container);
+ expect(h.length).toBeGreaterThan(0);
+ return h;
+ });
+
+ expect(headers).toContain('Opportunity Name');
+ // The pre-existing object-schema gate dropped it.
+ expect(headers).not.toContain('Amount');
+ // MECHANISM: `id` is `hidden`, so the object-schema policy excludes it while
+ // the inline-data row-key derivation would include it. Its absence is what
+ // shows the inline branch (the one #6723 touches) did not run here.
+ expect(headers).not.toContain('Id');
+ });
+
+ it('CONTROL: with no permission policy mounted, `Amount` renders in both shapes', async () => {
+ // Without this the two denials above would also pass on a grid that failed
+ // to render the column for some unrelated reason.
+ const authored = renderList(listSchema({ columns: ['name', 'amount'] }));
+ await waitFor(() => expect(dataHeaders(authored.container)).toEqual(['Opportunity Name', 'Amount']));
+ cleanup();
+
+ const unauthored = renderList(listSchema());
+ await waitFor(() => expect(dataHeaders(unauthored.container)).toContain('Amount'));
+ expect(dataHeaders(unauthored.container)).toContain('Opportunity Name');
+ });
+});
diff --git a/vitest.config.mts b/vitest.config.mts
index 5c1a76e06..96383af6b 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -139,6 +139,12 @@ const heavyDomTests = [
// all eight at once, and a stub grid stands in for one side of the
// ListView/ObjectGrid disagreement it has to observe.
'packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx',
+ // objectui#6723 — PIN 4: the grid's new inline-data FLS gate must be a
+ // no-op through this host, which pre-filters its own fields. "No-op"
+ // is a claim about the REAL grid's rendered headers, so a stub grid
+ // (what the sibling handoff pins register) cannot observe it. Same
+ // reason and same route as the three entries above.
+ 'packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx',
];
export default defineConfig({