Skip to content

fix(app-shell): the permission matrix models the artifact tier — no Save that 403s on multi-env kernels (#4518) - #4525

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4518-matrix-artifact-tier
Aug 13, 2026
Merged

fix(app-shell): the permission matrix models the artifact tier — no Save that 403s on multi-env kernels (#4518)#4525
yinlianghui merged 1 commit into
mainfrom
claude/issue-4518-matrix-artifact-tier

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4518

Placeholders below are written {n} / {pkg} rather than in angle brackets, and one line of test output is transcribed rather than pasted: GitHub's body sanitizer strips an angle bracket followed by a letter as an HTML tag at rest, and would eat them.

The two tiers

The server's metadata write gate is two tiers. #4446 (PR #4519) modelled the first; this adds the second. From packages/metadata-protocol/src/protocol.ts:

1. Type tier — refuse when BOTH flags are false:

if (!overlayAllowed && !runtimeCreateAllowed) { ... }

2. Artifact tier — for an item a code package SHIPS, allowRuntimeCreate is not enough:

if (this.environmentId !== undefined) {
    const artifactBacked = this.isArtifactBacked(request.type, request.name);
    if (artifactBacked && !overlayAllowed) {
        // [not_overridable] ... provided by a code package
        // and the type has not opted into per-org overlay writes
        // status 403
    }
}

The method's own doc states the split: distinguish "overlaying a packaged item" (requires allowOrgOverride) from "authoring a DB-only item" (requires only allowRuntimeCreate). permission sits exactly in the gap — allowOrgOverride: false (ADR-0005 forbids per-org overlay of a packaged set: silent privilege drift) with allowRuntimeCreate: true.

So after #4519 opened the type tier, an environment-scope edit of a code-declared set rendered live checkboxes and a Save button that failed at the end with a 403 instead of a surface that explains itself up front.

The fix

ResourceEditPage:1332 has modelled both tiers all along. This is that same three-way rule, computed off the layered envelope PermissionMatrixEditor already fetches — no new probe, per the ruling:

const artifactTierApplies = !packageId && codeIsArtifact;
const canWriteByType = artifactTierApplies
  ? !!entry?.allowOrgOverride
  : !!(entry?.allowOrgOverride || entry?.allowRuntimeCreate);
const writable = canWriteByType && !readOnly;

codeIsArtifact is the sibling's predicate, sys_metadata provenance sentinel included — a published ORG set also surfaces its active version in code, and the server's isArtifactBacked excludes exactly that sentinel ("lookupArtifactItem only returns items whose _packageId marks a genuine code package"). Dropping it would lock every org-authored set the moment it was published.

The scoping condition, and one honest deviation from the ruling's letter

The ruling says to take the scoping condition "from the sibling". Measured: the sibling has noneResourceEditPage's canWriteByType scopes only on createMode, so mirroring it verbatim would have re-locked exactly the case the ruling's binding constraint protects. Reported rather than silently resolved.

The server's artifact tier is environmentId !== undefined-scoped, and that key is not visible to a client: it is a server-side row-scoping property of the kernel, the console never passes one to useMetadataClient, and MetadataClient bakes it into a private base URL. The only place it surfaces is GET /discovery — the probe the ruling forbids.

So the condition used is the one the filing itself names and this component already holds: packageId. Under a packageId the write is a package-door draft (ADR-0086 P0/P2) whose measured behaviour is 200 — that is #4446's headline case (PUT /api/v1/meta/permission/{n}?package={pkg} on the single-kernel showcase), which stays writable. It also cannot regress there: a code-defined package arrives with the host readOnly prop set, which dominates every other gate anyway.

Known, deliberate residue (in the code comment, not hidden): on a single kernel the server disengages its artifact tier entirely, so an env-scope edit of a code-declared set there would be accepted (200) while this renders read-only. That is the conservative direction — an honest lock rather than a Save that 403s — and closing it needs the kernel's environment topology on the client, i.e. the forbidden probe.

Honest caption

A third reason in the same badge slot, not a borrowed one. Naming the type would be the mirror image of the wording #4446 removed: the type does have a runtime write channel — a brand-new set authored on this screen saves fine — so what is locked is this one set, because a code package provides it.

Read-only (this set is provided by a code package)

The hint carries the server's own reason and its documented remedy (allowOrgOverride, not_overridable, OS_METADATA_WRITABLE), the same place the 403 text puts it — never in the label. New rows go through the metadata-admin defaults maps (EN + ZH), the channel this surface uses, following #4519's shape.

The header hero badge is deliberately untouched and is not a re-run of the #4036 divergence: PageShell's WritabilityBadge renders "create-only" for this shape, whose tooltip already reads "Code-shipped items are locked; new items can be created at runtime". That is precisely what the artifact tier says, so the two renderings agree. A case pins it.

Red-first

Directions were predicted before the revert; the run matched exactly — 3 red, 9 green. Reverting only the two source files to origin/main (172c73e) and keeping the new suite:

FAIL ... > code-declared set at environment scope is READ-ONLY - the server refuses this write
AssertionError: expected [ button element ] to be null

FAIL ... > ...and the caption names the ARTIFACT tier, not the type and not the package
TestingLibraryElementError: Unable to find an element with the text: /provided by a code package/.

FAIL ... > a code layer with no _packageId at all is treated as artifact-backed
AssertionError: expected [ button element ] to be null

Tests  3 failed | 9 passed (12)

(The two AssertionError lines read expected + the rendered Save button element + to be null in the real output; the element is transcribed as [ button element ] here for the sanitizer reason noted at the top.)

The 9 that stay green are green on purpose — they are the must-not-change set: the #4446 headline case under a packageId, runtime-created sets at both scopes, the sys_metadata sentinel, an overlay-allowed type, both-flags-false still naming the TYPE, a failed layered read failing open, and the readOnly prop still winning and still naming the PACKAGE.

Verification

  • Build closure first: pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build → exit 0.
  • pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/views/metadata-admin/ packages/app-shell/src/views/studio-design/166 files / 1660 passed, 1 skipped (studio-design hosts this editor in the Access pillar).
  • All 9 PermissionMatrixEditor.* suites, verbose → 49 passed, including fix(app-shell): the permission matrix honors allowRuntimeCreate — and the read-only banner tells the truth (#4446) #4519's 16 pins (readonly 7 + readonlyHeaderBadge 9, four-state convergence table included) and the 12 new ones.
  • Both tsc projects: tsc --noEmit → exit 0; tsc -p tsconfig.test.json → exit 0.
  • eslint on the three changed files → 0 errors, 22 warnings. Measured as pre-existing: PermissionMatrixEditor.tsx reports 20 warnings on origin/main and 20 with the fix; i18n.ts 0; the new suite's 2 are the fixture as any casts every sibling suite uses.
  • Gates: check:control-bytes OK (4238 tracked files), check:i18n-keys OK (2888 keys, every call-site key resolves), check:i18n-drift OK (0 en values changed — it scopes to packages/i18n/src/locales, and these rows live in the app-shell defaults maps), changeset:check OK, check:phantom-deps OK.

.d.ts measured both ways: built @object-ui/app-shell with the fix and again with the two source files reverted, hashing every emitted .d.ts. Identical — b3d87a1ab9c3fb2df691f9050716e20a2f6cd163a86d32c35dfbac6777d1639e both times. The new helper is module-local and the new state is internal, so there is no public type surface change: the changeset is patch.

The fix was taken out and restored with git checkout + a patch file, never git stash (shared stack), and both restores were verified byte-identical by sha256.


Generated by Claude Code

…ave that 403s on multi-env kernels (#4518)

The server's metadata write gate is two tiers and this editor modelled only
the first. After #4446 (PR #4519) opened the type tier, an environment-scope
edit of a CODE-DECLARED permission set offered live checkboxes and a Save
button that failed at the end with 403 not_overridable.

`saveMetaItem` refuses a second time, after the type-tier disjunction has
already passed: for an item a code package ships, `allowRuntimeCreate` is not
enough, because overwriting a packaged item is an OVERLAY and overlaying needs
`allowOrgOverride`. `permission` sits exactly in that gap.

The editor now computes the same three-way rule `ResourceEditPage:1332` has
modelled all along, `sys_metadata` provenance sentinel included, read off the
layered envelope it already fetches. No new probe.

Scoped to the environment door via `packageId`: under a packageId the write is
a package-door draft (ADR-0086 P0/P2) whose measured behaviour is 200, and a
code-defined package there already arrives with the host `readOnly` prop set.
So #4446's headline case stays writable, as do runtime-created sets at both
scopes.

The new read-only case gets its own caption. Naming the type would be the
mirror image of the wording #4446 removed: the type does have a runtime write
channel — what is locked is this one set, because a code package provides it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 4:07am

Request Review

@github-actions github-actions Bot added the tests label Aug 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 156.23KB 42.29KB
fields (index.js) 230.14KB 57.12KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.07KB 17.65KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.40KB 50.10KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Copy link
Copy Markdown
Collaborator Author

PM step-7 复核 — ACCEPT (session_017Qqyix2QcnpUC9XeYVDzx3)

Auto-merge armed (squash).


Generated by Claude Code


Generated by Claude Code

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

Labels

Projects

None yet

2 participants