diff --git a/.changeset/permission-matrix-models-the-artifact-tier-4518.md b/.changeset/permission-matrix-models-the-artifact-tier-4518.md new file mode 100644 index 000000000..3d5b59555 --- /dev/null +++ b/.changeset/permission-matrix-models-the-artifact-tier-4518.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +The permission matrix models the server's artifact tier — no Save that 403s on a code-declared set + +The server's metadata write gate is **two** tiers, and the permission-matrix editor modelled only the first. After the type tier was opened (#4446), an environment-scope edit of a **code-declared** permission set rendered live checkboxes and a Save button that failed at the end with `403 not_overridable` instead of a surface that explains itself up front. + +The second tier is the one `saveMetaItem` applies 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 — `allowOrgOverride: false` (ADR-0005 forbids per-org overlay of a packaged set: silent privilege drift) with `allowRuntimeCreate: true`. The editor now computes the same three-way rule `ResourceEditPage` has modelled all along, read off the layered envelope it already fetches — including the `sys_metadata` provenance sentinel, so a **published org set** stays editable instead of being mis-read as a packaged one. No new server round trip. + +It is scoped to the environment door. Under a `packageId` the write is a package-door draft (ADR-0086 P0/P2) and the measured behaviour is 200, so the #4446 headline case — a code-declared set on the single-kernel showcase — stays writable exactly as it was; a code-defined package there is already locked by the package-level read-only gate, which still dominates every other gate. Runtime-created sets stay editable at both scopes. + +The new read-only case gets its own caption rather than borrowing an existing one. Naming the type would be the mirror image of the wording #4446 removed: the type *does* have a runtime write channel, and a brand-new set authored on this screen still saves fine — what is locked is this one set, because a code package provides it. The caption says that, and the hint carries the server's own reason and its documented remedy. diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.artifactTier.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.artifactTier.test.tsx new file mode 100644 index 000000000..c476f95d3 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.artifactTier.test.tsx @@ -0,0 +1,331 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ARTIFACT tier of the permission matrix's write gate — objectui#4518. + * + * ## The two tiers + * + * The server's metadata write gate is TWO tiers, and objectui#4446 (PR #4519) + * modelled only the first. From metadata-protocol `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) { … status 403 not_overridable }` + * `}` + * + * The method's own doc states the split: "overlaying a packaged item" (requires + * `allowOrgOverride`) vs "authoring a DB-only item" (requires only + * `allowRuntimeCreate`). `permission` sits exactly in the gap — `false` / + * `true` — so after #4446 opened the type tier, an environment-scope edit of a + * CODE-DECLARED set rendered 207 live checkboxes and a Save button that failed + * at the end with a 403 instead of a surface that explains itself up front. + * + * `ResourceEditPage:1332` has modelled both tiers all along, `sys_metadata` + * sentinel included. This suite pins that same model here. + * + * ## The scoping condition, and why it is `packageId` + * + * 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. Reading it would mean a + * new `GET /discovery` probe, which the #4518 ruling forbids. + * + * The condition used instead is the one the filing names and this component + * already holds: `packageId`. Under a `packageId` the write is a package-door + * DRAFT (ADR-0086 P0/P2) and the measured behaviour is 200 — that is #4446's + * own headline case (a code-declared set on the single-kernel showcase, + * `PUT /api/v1/meta/permission/?package=` → 200), which this must NOT + * re-lock, and the cases below pin that it does not. The artifact case under a + * `packageId` is covered by the host `readOnly` prop the Studio Access pillar + * passes for a code-defined package, which dominates every other gate anyway. + * + * ## Red-first + * + * The first case fails on `origin/main` (172c73e6e, PR #4519 merged): Save is + * offered and every checkbox is live for a set the server refuses. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +// ── Fake metadata client ──────────────────────────────────────────────────── +// +// `codeLayer` is the whole point of this suite: it is what the server's +// `isArtifactBacked` answers from, mirrored into the layered envelope's `code` +// slot the editor already fetches. +// +// • `null` → runtime-created (DB-only) set +// • `{ _packageId: 'sys_metadata' }` → published ORG set (the sentinel) +// • `{ _packageId: 'com.example.…' }` → shipped by a code package +let codeLayer: Record | null = null; + +const SET = { + name: 'showcase_contributor', + label: 'Contributor', + objects: { a_account: { allowRead: true, allowCreate: true } }, + fields: {}, +} as Record; + +function makeClient() { + return { + layered: async () => ({ + effective: SET, + code: codeLayer, + overlay: null, + overlayScope: null, + }), + getDraft: async () => null, + list: async (type: string) => + type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [], + get: async (type: string) => + type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null, + save: async (_t: string, _n: string, payload: Record) => payload, + } as any; +} + +// The stock-boot `permission` shape (objectstack#6483) unless a case says +// otherwise: no per-org overlay, but runtime authoring is open. +let typeFlags: { allowOrgOverride?: boolean; allowRuntimeCreate?: boolean } = { + allowOrgOverride: false, + allowRuntimeCreate: true, +}; +let clientImpl: any; + +vi.mock('./useMetadata', () => ({ + useMetadataClient: () => clientImpl, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', ...typeFlags }], + }), +})); + +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; + +afterEach(() => { + cleanup(); + codeLayer = null; + typeFlags = { allowOrgOverride: false, allowRuntimeCreate: true }; +}); + +async function renderMatrix(props?: { packageId?: string; readOnly?: boolean }) { + clientImpl = makeClient(); + render( + + + , + ); + await screen.findByText('Account'); +} + +/** The identity-strip read-only badge, or null when the surface is writable. */ +function lockBadge(): HTMLElement | null { + return ( + screen.queryByText(/^Read-only \(/) ?? + screen.queryByText('Read-only', { exact: true }) + ); +} + +const ARTIFACT_CAPTION = /provided by a code package/; +const TYPE_CAPTION = /no runtime write channel/; + +/* ────────────────────────────────────────────────────────────────────────── */ + +describe('PermissionMatrixEditPage — the ARTIFACT tier locks an env-scope overlay (#4518)', () => { + /** + * RED-FIRST. Environment scope (no `packageId`) + a set a code package ships + * + `allowOrgOverride: false`. The server answers this write with + * 403 `not_overridable`; pre-fix the editor offered Save anyway. + */ + it('code-declared set at environment scope is READ-ONLY — the server refuses this write', async () => { + codeLayer = { _packageId: 'com.example.showcase', name: 'showcase_contributor' }; + await renderMatrix(); + + // No Save that would 403. + expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull(); + + // Every grant control is locked… + expect(screen.getByLabelText('a_account Read')).toBeDisabled(); + for (const box of screen.getAllByRole('checkbox')) expect(box).toBeDisabled(); + const row = screen.getByText('Account').closest('tr')!; + for (const n of ['R', 'CRUD', 'All', 'None']) { + expect(within(row).getByRole('button', { name: n })).toBeDisabled(); + } + }); + + it('…and the caption names the ARTIFACT tier, not the type and not the package', async () => { + codeLayer = { _packageId: 'com.example.showcase' }; + await renderMatrix(); + + const badge = screen.getByText(ARTIFACT_CAPTION); + expect(badge).toBeInTheDocument(); + + // NOT the type-tier wording: this type DOES have a runtime write channel — + // a brand-new set authored here saves fine. Blaming the type would be the + // mirror-image lie of the one #4446 removed. + expect(screen.queryByText(TYPE_CAPTION)).toBeNull(); + // NOT the package wording either — no read-only PACKAGE is involved. + expect(screen.queryByText('Read-only', { exact: true })).toBeNull(); + + // The hint carries the server's own reason and its own documented remedy, + // the same place the 403 text puts it. + expect(badge).toHaveAttribute('title', expect.stringContaining('allowOrgOverride')); + expect(badge).toHaveAttribute('title', expect.stringContaining('not_overridable')); + expect(badge).toHaveAttribute('title', expect.stringContaining('OS_METADATA_WRITABLE')); + // …and never in the label (objectui#4446's rule for this badge slot). + expect(screen.queryByText(/OS_METADATA_WRITABLE/)).toBeNull(); + }); + + /** + * The header hero badge is NOT re-litigated here, and it is not a + * contradiction: `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 exactly what the artifact tier + * says, so the two renderings agree — unlike the #4036 divergence, where + * "create-only" sat over dead checkboxes for a set that was NOT code-shipped. + */ + it('the header hero badge stays "create-only" and never claims "writable"', async () => { + codeLayer = { _packageId: 'com.example.showcase' }; + await renderMatrix(); + + expect(screen.getByText('create-only')).toBeInTheDocument(); + expect(screen.queryByText('writable')).toBeNull(); + }); + + it('an overlay-allowed type is still writable even when code-declared', async () => { + // `allowOrgOverride: true` IS permission to overlay a packaged item — the + // artifact tier's own condition (`artifactBacked && !overlayAllowed`) is + // then false and the server accepts the write. + typeFlags = { allowOrgOverride: true, allowRuntimeCreate: false }; + codeLayer = { _packageId: 'com.example.showcase' }; + await renderMatrix(); + + expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled(); + expect(screen.getByLabelText('a_account Read')).toBeEnabled(); + expect(lockBadge()).toBeNull(); + }); + + it('both flags false + code-declared still names the TYPE — the broader refusal', async () => { + // The server reaches `!overlayAllowed && !runtimeCreateAllowed` FIRST, and + // "this type accepts no runtime write at all" is the honest reason; the + // artifact tier is only the DECIDING gate where the type tier said yes. + typeFlags = { allowOrgOverride: false, allowRuntimeCreate: false }; + codeLayer = { _packageId: 'com.example.showcase' }; + await renderMatrix(); + + expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull(); + expect(screen.getByText(TYPE_CAPTION)).toBeInTheDocument(); + expect(screen.queryByText(ARTIFACT_CAPTION)).toBeNull(); + }); +}); + +describe('PermissionMatrixEditPage — MUST NOT CHANGE: what the artifact tier may not re-lock (#4518)', () => { + /** + * The binding constraint of the #4518 ruling. #4446's headline case, measured + * on a live QA run (objectstack#7637) as `PUT /api/v1/meta/permission/ + * ?package=` → 200 on the single-kernel showcase: a code-declared set + * under the PACKAGE door. The artifact tier must not touch it. + */ + it('code-declared set under a packageId stays EDITABLE — measured 200 (#4446 headline)', async () => { + codeLayer = { _packageId: 'com.example.showcase' }; + await renderMatrix({ packageId: 'com.example.showcase' }); + + expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled(); + expect(screen.getByLabelText('a_account Read')).toBeEnabled(); + const row = screen.getByText('Account').closest('tr')!; + for (const n of ['R', 'CRUD', 'All', 'None']) { + expect(within(row).getByRole('button', { name: n })).toBeEnabled(); + } + expect(lockBadge()).toBeNull(); + }); + + it('runtime-created set stays EDITABLE at environment scope', async () => { + codeLayer = null; + await renderMatrix(); + + expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled(); + expect(screen.getByLabelText('a_account Read')).toBeEnabled(); + expect(lockBadge()).toBeNull(); + }); + + it('runtime-created set stays EDITABLE under a packageId', async () => { + codeLayer = null; + await renderMatrix({ packageId: 'com.example.showcase' }); + + expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled(); + expect(screen.getByLabelText('a_account Read')).toBeEnabled(); + expect(lockBadge()).toBeNull(); + }); + + /** + * The `sys_metadata` provenance sentinel, mirrored from the server's + * `isArtifactBacked` ("`lookupArtifactItem` only returns items whose + * `_packageId` marks a genuine code package (the `'sys_metadata'` + * rehydration sentinel is excluded)"). A PUBLISHED org set surfaces its + * active version in `code` too — dropping the sentinel would lock every + * org-authored set the moment it was published. + */ + it('a published ORG set (sys_metadata sentinel) stays EDITABLE at environment scope', async () => { + codeLayer = { _packageId: 'sys_metadata', name: 'showcase_contributor' }; + await renderMatrix(); + + expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled(); + expect(screen.getByLabelText('a_account Read')).toBeEnabled(); + expect(lockBadge()).toBeNull(); + }); + + it('a code layer with no _packageId at all is treated as artifact-backed', async () => { + // Only the sentinel is carved out. An untagged `code` layer is a code layer: + // the fail-safe direction for a shape neither side has seen. + codeLayer = { name: 'showcase_contributor' }; + await renderMatrix(); + + expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull(); + expect(screen.getByText(ARTIFACT_CAPTION)).toBeInTheDocument(); + }); + + it('a failed layered read does NOT invent a lock (fail-open, as the sibling does)', async () => { + clientImpl = { + ...makeClient(), + layered: async () => { + throw new Error('boom'); + }, + }; + render( + + + , + ); + await screen.findByText('Account'); + + // `layered?.code != null` is exactly how `ResourceEditPage:1332` fails too, + // and it is the pre-#4518 behaviour. The save still meets the server's gate. + expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled(); + expect(lockBadge()).toBeNull(); + }); + + it('the readOnly prop still wins, and still names the PACKAGE', async () => { + // "Studio 维持包级只读" (objectstack#5768 A2) — the host gate dominates every + // other gate, and the badge must not borrow the artifact wording for it. + codeLayer = { _packageId: 'com.example.showcase' }; + await renderMatrix({ packageId: 'com.example.showcase', readOnly: true }); + + expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull(); + for (const box of screen.getAllByRole('checkbox')) expect(box).toBeDisabled(); + const badge = screen.getByText('Read-only', { exact: true }); + expect(badge).toHaveAttribute('title', expect.stringContaining('Read-only package')); + expect(screen.queryByText(ARTIFACT_CAPTION)).toBeNull(); + expect(screen.queryByText(TYPE_CAPTION)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index e7fb3ffa1..2eae15de9 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -109,6 +109,33 @@ interface ObjectSummary { owdExternal?: string; } +/** + * Is this item backed by a **code-package artifact**? (objectui#4518) + * + * The client-side mirror of the server's `isArtifactBacked` + * (metadata-protocol `protocol.ts`), and byte-for-byte the same predicate + * `ResourceEditPage:1332` already computes inline for its own two-tier gate. + * + * A non-null `code` layer alone is NOT proof of a code package: a published + * ORG item also surfaces its active version in `code`, tagged with the + * `sys_metadata` provenance sentinel. The server excludes exactly that + * sentinel ("`lookupArtifactItem` only returns items whose `_packageId` marks + * a genuine code package (the `'sys_metadata'` rehydration sentinel is + * excluded)"), so an org-authored set stays editable after publish instead of + * being mis-read as a read-only packaged item. + * + * `null` / a failed layered read answers `false` — "no artifact known". That + * is the fail-OPEN direction on purpose: it is what the sibling's + * `layered?.code != null` does, it is the pre-#4518 behaviour, and a transient + * read failure must not invent a lock. The cost is bounded and honest — the + * save still round-trips to the server's own gate. + */ +function isArtifactBackedLayer(layered: { code?: unknown } | null | undefined): boolean { + const code = layered?.code; + if (code == null) return false; + return (code as { _packageId?: string })._packageId !== 'sys_metadata'; +} + /** Localized short label for an OWD value; falls back to the raw value. */ function owdLabel(t: (k: string) => string, value: string): string { const key: Record = { @@ -210,8 +237,13 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, const adapter = useAdapter(); const { entries } = useMetadataTypes(client); const entry: RichMetadataTypeEntry | undefined = entries.find((t) => t.type === type); - // Two independent read-only gates, and each reads the flag that actually - // governs it (objectui#4446): + // Does a code package SHIP this set? Read off the layered envelope the load + // effect below already fetches, via {@link isArtifactBackedLayer}. Starts + // `false` ("no artifact known") and is re-derived on every load, so a slow or + // failed read never invents a lock — see the helper's doc (objectui#4518). + const [codeIsArtifact, setCodeIsArtifact] = React.useState(false); + // Three independent read-only gates, and each reads the fact that actually + // governs it (objectui#4446, #4518): // // • TYPE gate — the metadata type must offer SOME runtime write channel. // That is the DISJUNCTION `allowOrgOverride || allowRuntimeCreate`, not @@ -237,17 +269,81 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // `allowOrgOverride` ONLY, so a `resolved.allowRuntimeCreate` would be // silently `undefined`. // + // • ARTIFACT gate — the server's SECOND tier, added by objectui#4518. See + // the block below the state declaration for why the type tier alone is + // not the whole gate. + // // • HOST gate — the package-level `readOnly` prop the Studio Access pillar // passes for a read-only package. UNCHANGED and still dominant: this is // the "Studio 维持包级只读" half of the objectstack#5768 ruling, and a // code-defined package stays locked here exactly as before. // - // Either gate locks every authoring affordance below. Note this predicate is - // now byte-identical to the one `PageShell`'s WritabilityBadge already uses - // (`readOnly` → `allowOrgOverride` → `allowRuntimeCreate` → read-only), so - // the header badge and these controls can no longer disagree — the very - // divergence recorded in `PermissionMatrixEditor.readonlyHeaderBadge.test.tsx`. - const writable = !!(entry?.allowOrgOverride || entry?.allowRuntimeCreate) && !readOnly; + // Any of the three locks every authoring affordance below. + // + // ── The ARTIFACT tier (objectui#4518) ───────────────────────────────────── + // + // The server's metadata write gate is TWO tiers, and #4446 modelled only the + // first. `saveMetaItem` refuses a second time, AFTER the type-tier + // disjunction above has already passed (metadata-protocol `protocol.ts`): + // + // if (this.environmentId !== undefined) { + // const artifactBacked = this.isArtifactBacked(request.type, request.name); + // if (artifactBacked && !overlayAllowed) { … status 403 not_overridable } + // } + // + // So for an item a code package SHIPS, `allowRuntimeCreate` is not enough — + // overwriting it is an OVERLAY, and overlaying needs `allowOrgOverride`. The + // method's own doc states the split: "overlaying a packaged item" (requires + // `allowOrgOverride`) vs "authoring a DB-only item" (requires only + // `allowRuntimeCreate`). `permission` sits exactly in the gap — `false` / + // `true` — so without this tier the matrix offered live checkboxes and a Save + // button that failed at the end with a 403 instead of a surface that explains + // itself up front. + // + // `ResourceEditPage:1332` has modelled both tiers all along; this is that + // same three-way rule, with the same `sys_metadata` sentinel (see + // {@link isArtifactBackedLayer}), read off the layered envelope this editor + // ALREADY fetches. No new probe — the ruling on #4518 forbids one, and the + // entry flags plus `layered.code` are the whole input. + // + // ── …scoped to the environment door, which is the binding constraint ────── + // + // The server's artifact tier is `environmentId !== undefined`-scoped, and a + // client cannot see that key: it is a SERVER-side row-scoping property of the + // kernel (`ObjectStackProtocolImplementation.environmentId`), the console + // never passes one to `useMetadataClient`, and `MetadataClient` bakes it into + // a private base URL. The only place it is readable is `GET /discovery` — a + // new probe, which is exactly what was ruled out. + // + // The condition used instead is the one the filing itself names, and it is a + // fact this component already holds: `packageId`. Under a `packageId` the + // write is a package-door DRAFT (ADR-0086 P0/P2) and the measured behaviour + // is 200 — that is the #4446 headline case (a code-declared set on the + // single-kernel showcase, `PUT …/permission/?package=` → 200), which + // this must NOT re-lock. It also cannot: under a `packageId` a code-defined + // package already arrives with the host `readOnly` prop set, so the artifact + // case is covered there by a gate that dominates anyway. The one uncovered + // surface — the metadata-admin route at environment scope — is precisely + // where this engages. + // + // Known, deliberate residue (reported with the fix, 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 would need the kernel's + // environment topology on the client, i.e. the probe the ruling forbids. + const artifactTierApplies = !packageId && codeIsArtifact; + const canWriteByType = artifactTierApplies + ? !!entry?.allowOrgOverride + : !!(entry?.allowOrgOverride || entry?.allowRuntimeCreate); + const writable = canWriteByType && !readOnly; + // Which gate to NAME when the surface is locked (host > artifact > type). + // The artifact tier is the DECIDING one only where the type tier would have + // said yes: with both flags false the honest reason is still "this type has + // no runtime write channel at all", which is also the refusal the server + // reaches first (`!overlayAllowed && !runtimeCreateAllowed`). + const lockedByArtifactTier = + artifactTierApplies && !entry?.allowOrgOverride && !!entry?.allowRuntimeCreate; const locale = useMetadataLocale(); const t = React.useCallback((k: string) => translate(k, locale), [locale]); const OBJECT_ACTIONS = React.useMemo(() => getObjectActions(locale), [locale]); @@ -311,6 +407,10 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, React.useEffect(() => { let cancelled = false; setLoading(true); + // Re-derived from the envelope below. Cleared here so a switch to another + // set can never carry the previous one's artifact verdict for a frame + // (objectui#4518). + setCodeIsArtifact(false); (async () => { try { const [lay, objList, pendingDraft] = await Promise.all([ @@ -327,6 +427,10 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, : Promise.resolve(null), ]); if (cancelled) return; + // ARTIFACT tier input (objectui#4518) — the `code` layer of the SAME + // envelope the display baseline comes from, so the writability verdict + // and the body on screen can never be read from different round trips. + setCodeIsArtifact(isArtifactBackedLayer(lay)); const draftBody = pendingDraft ? (((pendingDraft as any).item ?? pendingDraft) as PermissionSetDraft) : null; @@ -750,11 +854,17 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, {t('perm.basics.editHint')} )} {!writable && ( - // Same badge slot, two distinct reasons, and each names the gate - // that ACTUALLY tripped (objectui#4446): + // Same badge slot, three distinct reasons, and each names the gate + // that ACTUALLY tripped (objectui#4446, #4518): // // • host gate — a read-only PACKAGE; mirror the top-bar wording // so the screen is not self-contradictory. + // • artifact gate — a code package SHIPS this set and the type + // has not opted into per-org overlay, so an environment-scope + // write of it is refused (403 `not_overridable`). Naming the + // type here would be a lie in the other direction: the type + // DOES have a runtime write channel — a brand-new set authored + // here saves fine — it is this PARTICULAR set that is packaged. // • type gate — the metadata type offers no runtime write // channel at all (`allowOrgOverride` AND `allowRuntimeCreate` // both false). It used to read "OS_METADATA_WRITABLE not @@ -770,9 +880,19 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, - {readOnly ? t('engine.studio.pkg.readonly') : t('perm.readOnly')} + {readOnly + ? t('engine.studio.pkg.readonly') + : lockedByArtifactTier + ? t('perm.readOnly.artifact') + : t('perm.readOnly')} )} diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 2d76b24d6..12d84c5cf 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1190,6 +1190,15 @@ const ENGINE_STRINGS_EN: Record = { 'perm.readOnly': 'Read-only (this metadata type has no runtime write channel)', 'perm.readOnly.hint': 'The metadata-type registry declares both allowOrgOverride and allowRuntimeCreate false for this type, so the platform accepts no runtime write for it. Edit the source artifact and redeploy, or ask an operator for the documented OS_METADATA_WRITABLE escape hatch.', + // objectui#4518 — the ARTIFACT tier, the server's SECOND refusal. Distinct + // from `perm.readOnly` above on purpose: the TYPE does have a runtime write + // channel here (a brand-new set authored on this screen saves fine), so + // blaming the type would be the mirror-image lie. What is locked is THIS set, + // because a code package ships it and overwriting a packaged item is an + // overlay — which `permission` has not opted into. + 'perm.readOnly.artifact': 'Read-only (this set is provided by a code package)', + 'perm.readOnly.artifact.hint': + 'A code package ships this permission set, so an environment-scope edit would overlay a packaged item — and the metadata-type registry declares allowOrgOverride false for permission (ADR-0005 forbids per-org overlay of a packaged set: silent privilege drift). The server refuses the write with 403 not_overridable. Edit the source artifact and redeploy, create a new runtime set instead, or ask an operator for the documented OS_METADATA_WRITABLE escape hatch.', // Designer wrapper 'designer.unsavedChanges': 'Unsaved changes', 'designer.editingOverlay': 'Editing overlay', @@ -2962,6 +2971,12 @@ const ENGINE_STRINGS_ZH: Record = { 'perm.readOnly': '只读(该元数据类型没有运行时写入通道)', 'perm.readOnly.hint': '元数据类型注册表对该类型声明 allowOrgOverride 与 allowRuntimeCreate 均为 false,平台不接受它的任何运行时写入。请修改源工件后重新部署,或由运维启用有文档记载的 OS_METADATA_WRITABLE 逃生阀。', + // objectui#4518 —见 EN 表同键注释:这是服务端的第二道闸门(artifact 层), + // 与上面的「类型没有写入通道」是两回事:该类型有运行时写入通道,被锁的是「这一个」 + // 由代码包提供的权限集。 + 'perm.readOnly.artifact': '只读(该权限集由代码包提供)', + 'perm.readOnly.artifact.hint': + '该权限集由代码包提供,因此在环境作用域下编辑它属于覆盖(overlay)已打包的条目;而元数据类型注册表对 permission 声明 allowOrgOverride 为 false(ADR-0005 禁止对已打包权限集做按组织覆盖:会造成权限静默漂移)。服务端会以 403 not_overridable 拒绝该写入。请修改源工件后重新部署,或改为新建一个运行时权限集,或由运维启用有文档记载的 OS_METADATA_WRITABLE 逃生阀。', // Designer wrapper 'designer.unsavedChanges': '未保存的修改', 'designer.editingOverlay': '编辑覆盖层',