Skip to content

feat(data-objectstack,app-shell,i18n): view config is explicitly org-wide — permission-gated write path + console wording - #6125

Merged
yinlianghui merged 3 commits into
mainfrom
claude/issue-5232-view-config-org-wide
Aug 25, 2026
Merged

feat(data-objectstack,app-shell,i18n): view config is explicitly org-wide — permission-gated write path + console wording#6125
yinlianghui merged 3 commits into
mainfrom
claude/issue-5232-view-config-org-wide

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#5232

Implements objectstack#7494's ruling (maintainer huangyiirene, 2026-08-12), verbatim:

裁定:今天的合同就是 org 级;per-user scope 留在 #7611(v18),不提前造
console 停止把 sort / hiddenFields / columnState / rowHeight 呈现为「个人配置」,措辞与 UX 改为「视图配置(对所有用户生效)」;写路径加权限门(视图设计类权限),普通用户不再能替全组织重排视图。

All gate results below are from c4f5dbf62, the branch head.

The premise, re-derived — and one half of it was already done

The card pointed at ObjectView.tsx ~1455 for prose calling these "persisted user preferences … per-view personal config". That prose no longer exists, in either ObjectView.tsx. A repo-wide search for the card's own quoted phrases returns three source matches, all code comments, in exactly the three files at issue — and all three already say the corrected thing, citing this same ruling ("ORG-WIDE shared, not a per-user preference"). The row-classification half left them that way.

What was not done is the part the ruling actually asks for: no user-visible string anywhere told an operator that these settings apply to everyone. Correct comments are not UX. So the wording work here is on the rendered strings, not the docblocks.

Census method, stated because the card admitted its own was not exhaustive ("and any sibling prose"): (a) git grep for each quoted phrase across all tracked files; (b) grep the four ruled property names in label/tooltip/i18n contexts; (c) trace every persistViewPatch call site backwards to the component that emits it, then enumerate the i18n keys those components render. (c) is what found the real site — ViewSettingsPopover, the popover that actually hosts density and field visibility, whose header hint is the one sentence an operator reads before acting.

The gate is on the write, not on the button

ObjectStackAdapter.updateViewConfig now refuses when the session's reported capability set lacks manage_metadata, throwing ViewConfigPermissionDeniedError. The check is the first statement in the method — before connect(), before the payload is assembled — so a refused call puts nothing on the wire.

Deliberately not the toolbar affordance: withholding the button leaves the method still accepting the call from anything else holding the adapter, whereas a gate on the write is inherited by every caller, present and future. No affordance was hidden or disabled in this PR.

Which permission, and why it was not minted. The ruling names a class ("视图设计类权限"), not a permission. manage_metadata is what this repo already treats as metadata-authoring authority: it is HomePage's AUTHORING_CAPABILITY, it is what PackageFormDialog reads a server 403 for, and capability.label.manage_metadata is already translated in all ten packs. Decisively — the gated write goes through client.meta.saveItem, the very same ADR-0005 metadata door the server already refuses without it. This applies the authority the server is already applying rather than inventing a parallel one.

Unknown fails OPEN, and that is a judgement worth reading twice. It is the doctrine PermissionContextValue.hasCapabilities states for every ADR-0066 gate here (objectui#4656, framework#3923): the server enforces regardless, so a client-side denial on missing data cannot protect anything the server was not already protecting — it can only break a permitted user. Failing closed would have refused the write on every deployment predating ADR-0066 and every host with no permission provider, which is "break the write for everyone" wearing the costume of a security fix. A reported empty grant gates strictly; the two are never collapsed, which is the one distinction MePermissionsProvider goes out of its way to preserve.

What an unpermitted caller gets — quoted

Not a silent no-op. The thrown error's message, verbatim:

View configuration is shared: changing "crm_lead.default" changes it for everyone who uses this view. That requires the "manage_metadata" capability, which this session does not hold.

And on screen, persistViewPatch's catch previously swallowed every failure into console.error. For a debounced toggle whose UI has already moved that would leave the operator looking at a density they did not get, discovering it on the next reload. A denied write now raises a toast:

View settings apply to everyone who uses this view, so changing them requires the Manage Metadata permission. Ask an administrator to make this change.

The generic console.error deliberately survives for every other failure — the branch is an addition, not a replacement, and that survival is pinned.

Wording

list.viewSettingsHint now reads "Grouping, color, density, and visible fields. Applies to everyone who uses this view." — in all ten packs, plus both inline defaultValue mirrors (ViewSettingsPopover and ListView's provider-less defaults map, which a test holds byte-identical to the en pack).

Scoped deliberately to the popover header rather than sprayed across every tooltip: that header is where an operator reads a description before acting, and one accurate sentence there beats the same clause repeated on four controls.

Verification

Ablation — the gate deleted, restored under trap … EXIT INT TERM with a cwd-independent git -C. My own edit was committed first, so the restore could not take it with the mutation. Mutation proven on disk by grepping the injected marker (1) and the removed text (0) separately, anchor uniqueness asserted before writing, landing site printed (index.ts:3873), git diff HEAD --stat empty afterwards. No rebuild needed: the test imports ./index, relative source.

Predicted before running: the four refusal cells red, the two permit controls plus three pure-function cells green. Observed exactly that — 4 failed | 5 passed.

Which assertions would still pass on a revert, stated so nobody has to guess: permits a session holding the capability and unreported capabilities fail OPEN would both still pass — they assert the write happens, which is what the code did before the gate. They are the controls, and they exist so "the gate works" cannot be satisfied by breaking the write for everyone; both directions run in the same file and the same run. Everything under refuses fails on a revert. Every cell builds its ownmakeMetaStore() — a shared spy would let one case's saveItem satisfy the next case's assertion.

Gates (exit code captured before any pipe; script name echoed on each type-check so none was a zero-match no-op):

gateexitnote / what a red would have meant
pnpm --filter @object-ui/data-objectstack type-check0the gate or error class does not compile
pnpm --filter @object-ui/app-shell type-check0systemPermissions / guard import wrong
pnpm --filter @object-ui/plugin-list type-check0popover edit broke a type
pnpm --filter @object-ui/i18n type-check0a pack edit broke the pack's shape
pnpm exec vitest run packages/data-objectstack/src packages/i18n/src packages/plugin-list/src0Test Files 144 passed / Tests 2187 passed
pnpm exec vitest run packages/app-shell/src packages/plugin-list/src0Test Files 566 passed / Tests 5732 passed | 1 skipped
pnpm --filter '...@object-ui/data-objectstack' type-check0downstream sweep, 0error TS
npx eslint <merge-base delta>0485 problems (0 errors, 485 warnings), all pre-existing
pnpm check:i18n-keys0a call-site key or inline default disagreeing with en
pnpm check:i18n-drift01 en value(s) changed …, 9 pack value(s) followed
node scripts/check-changeset-presence.mjs014 source file(s) of 4 released package(s) changed … 1 changeset(s)
node scripts/check-changeset-no-major.mjs0a major bump
node scripts/check-changeset-fixed.mjs0package outside the fixed group
node scripts/check-control-bytes.mjs0scanned 5063 tracked text file(s)
node scripts/check-type-check-coverage.mjs0a package dropping out of type-check
pnpm check:phantom-deps0an undeclared import
pnpm check:self-import0a package naming itself
pnpm check:esm-specifiers0an extensionless relative specifier
pnpm check:spec-symbols0a hand-written name the spec already owns

Downstream sweep direction, demonstrated rather than claimed:'...@object-ui/data-objectstack' is the PREFIX form and resolves to 34 packages including app-shell, console, site and every plugin-* — the consumers a contract change lands on. The suffix form '@object-ui/data-objectstack...' resolves to only core and types, the upstream deps. Zero error TS anywhere in the prefix run. ./examples/** was built alongside ./packages/**, which is why no apps/examples workspace produced the unbuilt-closure red this sweep is known for.

Gate set derived by enumerating each CI job's own step list rather than top-level script names.

Three ratchets moved, and why that is not a regression

Adding one key to a live namespace tripped three counts. The invariants are untouched and each count carries its provenance inline:

  • de-quote-pairing-3876 — 52 → 53 correctly paired spans, and { open: 53, close: 53, rdq: 0 }. rdq staying at 0 is the load-bearing half: the new German value added a matched „…“ pair, not a stray closer that would have made close === open true for the wrong reason.
  • objectView-config-keys-retired-4730 — surviving keys 93 → 94, total 209 → 210. The retired half (116) does not move and is still pinned twice; splitting the two is the point, since folding a new key into the total would be indistinguishable from a retired key coming back.

Scope

Per-user view config was not built — no storage, no fallback, no flag (parked upstream as objectstack#7611, v18). persistViewPatch merged-base freezing is a separate card and is untouched here. The row-classification half was already complete and is untouched. No sibling defects were found worth filing.

Contended file — the HARD SERIAL condition, checked

Dispatch flagged packages/data-objectstack/src/index.ts as contended by PR #6109 (per-column sortability), with the instruction to stop if the edit lands in or adjacent to fetchObjectSchemaFresh. It does not.updateViewConfig is a different method roughly 300 lines away, with getObjects, getClient, getDiscovery, invalidateViewKeys, listViewOverrides and getView between them; my edits sit at its head and among the error classes near the top of the file. PR #6109 had not landed at branch time, so this branches from origin/main at e7957ab87 — declared rather than assumed, since waiting on an unmerged sibling is not a terminal state this seat can reach.

Re-measured after #6109 landed (it is now on main as 490d9a93a): git merge-tree --write-tree origin/main HEAD returns exit 0, no conflict, against a main that has also since taken #6117. The two changes touch the same file and do not overlap — which is what the dispatch's condition was asking, now answered by measurement rather than by reading line numbers. The branch is left un-merged so the gate results above keep pointing at the tree they were run on; the merge is the PM's to make.


Generated by Claude Code

os-litantand others added 3 commits August 24, 2026 18:03
…write path
objectstack#7494's ruling (maintainer, 2026-08-12): sort / hiddenFields /
columnState / rowHeight are ORG-WIDE view configuration, not per-user
preferences. Gate `updateViewConfig` on the `manage_metadata` capability the
repo already uses for metadata authoring, refuse before anything reaches the
wire, surface the refusal instead of swallowing it, and say the scope in the
View settings popover before the operator acts.
Unknown capabilities fail OPEN (ADR-0066 doctrine, objectui#4656); a reported
empty grant gates strictly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
`viewConfigPermissionDenied` adds one key to a live namespace and one correctly
paired German „…“ span. The invariants themselves are untouched: de still shows
open === close with rdq at 0, and none of the 116 retired objectView keys came
back. Only the counts move, each with its provenance recorded inline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
Closes the loop from both ends: the real adapter's refusal is classified by the
real guard the catch branches on, and the key that branch renders exists in the
en pack. Also pins that the generic console.error SURVIVES for every other
failure — the branch is an addition, not a replacement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.3 KB3990.2 KB
Main entry chunk (gzip)153.8 KB350 KB
Entry fileindex-C4jX4kSa.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)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
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)505.15KB114.53KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.14KB61.94KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)201.05KB54.38KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
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)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
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)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
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)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement the objectstack#7494 ruling: view config is explicitly org-wide — console wording/UX + permission-gated write path

2 participants

@yinlianghui@os-litant