From 51420e4d925b9380196880e5a1aed81fa1cb1d75 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 02:51:20 +0000 Subject: [PATCH] =?UTF-8?q?fix(app-shell):=20the=20permission=20matrix=20h?= =?UTF-8?q?onors=20allowRuntimeCreate=20=E2=80=94=20and=20the=20read-only?= =?UTF-8?q?=20banner=20tells=20the=20truth=20(#4446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The writability switch read `allowOrgOverride` alone, so a writable package rendered read-only while the server accepted the package-door write. It now reads the disjunction `allowOrgOverride || allowRuntimeCreate` off the raw server entry — the repo's own convention and the server's own predicate. The package-level `readOnly` gate is untouched and still dominant. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- ...matrix-honors-allow-runtime-create-4446.md | 15 +++ .../PermissionMatrixEditor.readonly.test.tsx | 127 ++++++++++++++++-- ...nMatrixEditor.readonlyHeaderBadge.test.tsx | 115 +++++++++++++++- .../metadata-admin/PermissionMatrixEditor.tsx | 81 ++++++++--- .../src/views/metadata-admin/i18n.ts | 14 +- 5 files changed, 321 insertions(+), 31 deletions(-) create mode 100644 .changeset/permission-matrix-honors-allow-runtime-create-4446.md diff --git a/.changeset/permission-matrix-honors-allow-runtime-create-4446.md b/.changeset/permission-matrix-honors-allow-runtime-create-4446.md new file mode 100644 index 0000000000..a702d71027 --- /dev/null +++ b/.changeset/permission-matrix-honors-allow-runtime-create-4446.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +--- + +The permission matrix honors `allowRuntimeCreate` — and its read-only badge names the gate that actually tripped + +On a stock boot the permission-matrix editor rendered every checkbox disabled, hid Save, and captioned itself `Read-only (OS_METADATA_WRITABLE not enabled)` — in a **writable** package, at both the metadata-admin route and inside Studio's Access pillar — while the server accepted the very write it was refusing to offer (`PUT /api/v1/meta/permission/?package=` → 200, measured on objectstack#7637). + +The editor's writability switch read `allowOrgOverride` alone. That flag and `allowRuntimeCreate` are two different doors: the first is permission to OVERLAY a code-shipped item per organization, the second is permission to AUTHOR an item at runtime — and authoring is what this editor's Save does under a `packageId` (`mode: 'draft'` + `packageId`, ADR-0086 P0/P2). The server refuses only when BOTH are false; `permission` sits in exactly the gap, `allowOrgOverride: false` (ADR-0005 forbids per-org overlay of a packaged permission set — silent privilege drift) with `allowRuntimeCreate: true`, which objectstack#6483 kept open deliberately. The switch now reads the disjunction, off the raw server entry, the way `DirectoryPage`, `EmbeddedItemEditor` and `ResourceEditPage` already read it and the way `useMetadata.ts` documents on the field itself. + +The package-level gate is untouched and still dominant: a read-only package locks this screen whatever the type permits, so a code-defined package behaves exactly as before. + +The badge and the controls also stop disagreeing, and the fix is what closed the gap rather than a second edit. `PageShell`'s writability badge was already reading the full disjunction (`readOnly` → `allowOrgOverride` → `allowRuntimeCreate`); the controls were the outlier, so the two predicates are now byte-identical and a four-state table pins them that way. Previously this screen showed a "create-only" badge above 207 dead checkboxes. + +The read-only caption is honest in both directions now. `OS_METADATA_WRITABLE` was never the type gate's cause — that variable does not sit beside `allowOrgOverride`, it flips it (`getMetaTypes` emits `allowOrgOverride: base.allowOrgOverride || isEnvOverridden`), so there was no reachable state in which the old sentence was the right explanation: whenever the hatch is on for a type, that type is writable and no read-only badge renders. The caption now names the per-type registry declaration that actually locked the surface, and keeps the env var where it belongs — as the documented remedy, in the hint. diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.readonly.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.readonly.test.tsx index b8809915e7..95a92c7e3f 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.readonly.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.readonly.test.tsx @@ -11,11 +11,41 @@ * * Two independent gates share one `writable` switch inside the editor: * • host gate — `readOnly` prop (read-only package in Studio), and - * • type gate — `allowOrgOverride: false` on the metadata type - * (environment-level OS_METADATA_WRITABLE). + * • type gate — the metadata type offers NO runtime write channel, i.e. + * `allowOrgOverride` AND `allowRuntimeCreate` are BOTH false. * Either one must disable every checkbox / bulk button / input, hide Save, * and show a read-only badge — worded for ITS reason, so the badge never * contradicts the surrounding surface. + * + * ## The type gate is a DISJUNCTION, and this suite used to say otherwise + * + * objectui#4446. Until then the switch read `allowOrgOverride` alone and this + * header called that flag "environment-level OS_METADATA_WRITABLE". Both were + * wrong, and in the same way — they collapsed two different doors into one: + * + * • `allowOrgOverride` = may an ORG OVERLAY a code-shipped item; + * • `allowRuntimeCreate` = may an item be AUTHORED at runtime — which is + * what this editor's Save actually does under a `packageId` + * (`mode: 'draft'` + `packageId`, ADR-0086 P0/P2). + * + * The server refuses only when BOTH are false (`!isOverlayAllowed && + * !isRuntimeCreateAllowed`, metadata-protocol `protocol.ts`), so gating on the + * first alone locked a surface the server accepts. `permission` is exactly that + * shape on a stock boot — `allowOrgOverride: false` (ADR-0005 forbids per-org + * overlay of a packaged permission set: silent privilege drift) with + * `allowRuntimeCreate: true`, which objectstack#6483 kept open on purpose. + * + * And `OS_METADATA_WRITABLE` was never the type gate's reason: it does not sit + * beside `allowOrgOverride`, it FLIPS it (`getMetaTypes` emits + * `allowOrgOverride: base.allowOrgOverride || isEnvOverridden`). So there is no + * reachable state in which the old badge text was the honest explanation — + * whenever the hatch is on for a type, that type is writable and no read-only + * badge renders at all. + * + * What this suite pins, therefore: the HOST gate is unchanged and still + * dominant (the "Studio 维持包级只读" half of the objectstack#5768 ruling), and + * the TYPE gate now trips only when the type really has no write channel — and + * says so. */ import '@testing-library/jest-dom/vitest'; @@ -58,9 +88,14 @@ function makeClient() { } as any; } -// Flipped per-test to drive the TYPE-level gate (resolveResourceConfig reads -// allowOrgOverride straight off the server entry). +// Flipped per-test to drive the TYPE-level gate. Both flags are read straight +// off the server entry, the way DirectoryPage / EmbeddedItemEditor / +// ResourceEditPage read them (objectui#4446). let allowOrgOverride = true; +// The other half of the disjunction. `false` is the historical default of this +// suite (the key simply did not exist in the fixture, so it read `undefined`); +// the stock-boot `permission` shape sets it true with allowOrgOverride false. +let allowRuntimeCreate = false; // One stable client per test — the editor's load effect depends on the client // reference, so a fresh object per render would re-trigger it forever. let clientImpl: any; @@ -70,7 +105,7 @@ vi.mock('./useMetadata', () => ({ useMetadataTypes: () => ({ loading: false, error: null, - entries: [{ type: 'permission', label: 'Permission', allowOrgOverride }], + entries: [{ type: 'permission', label: 'Permission', allowOrgOverride, allowRuntimeCreate }], }), })); @@ -79,6 +114,7 @@ import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; afterEach(() => { cleanup(); allowOrgOverride = true; + allowRuntimeCreate = false; server.saved = []; }); @@ -150,14 +186,89 @@ describe('PermissionMatrixEditPage — read-only package gate (host readOnly)', }); describe('PermissionMatrixEditPage — type-level gate keeps its own wording', () => { - it('allowOrgOverride=false hides Save and shows the environment badge', async () => { + it('no write channel at all (both flags false) hides Save and names the TYPE as the reason', async () => { allowOrgOverride = false; + allowRuntimeCreate = false; renderMatrix(); await screen.findByText('Account'); expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull(); expect(screen.getByLabelText('a_account Read')).toBeDisabled(); - // Env-gate reason, untouched by the package gate. - expect(screen.getByText(/OS_METADATA_WRITABLE/)).toBeInTheDocument(); + + // Type-gate reason, untouched by the package gate — and it names the type, + // not a deployment env var (objectui#4446). The old wording claimed + // "OS_METADATA_WRITABLE not enabled", which no reachable state supports: + // that hatch FLIPS allowOrgOverride to true, so when it is on this badge + // does not render. + expect(screen.queryByText(/OS_METADATA_WRITABLE/)).toBeNull(); + const badge = screen.getByText(/no runtime write channel/); + expect(badge).toBeInTheDocument(); + // The env var survives as the documented REMEDY, in the hint only. + expect(badge).toHaveAttribute('title', expect.stringContaining('allowRuntimeCreate')); + expect(badge).toHaveAttribute('title', expect.stringContaining('OS_METADATA_WRITABLE')); + // Package wording must NOT be borrowed for a type-gate lock. + expect(screen.queryByText('Read-only', { exact: true })).toBeNull(); + }); + + /** + * ── The defect objectui#4446 was filed for ──────────────────────────────── + * + * The stock-boot `permission` shape, measured on a live QA run + * (objectstack#7637): the registry declares `allowOrgOverride: false` and + * `allowRuntimeCreate: true`, and the server ACCEPTS the package-door write + * (`PUT /api/v1/meta/permission/?package=` → 200). Pre-fix the editor + * disabled all 207 checkboxes and hid Save anyway, then blamed + * `OS_METADATA_WRITABLE`. + * + * RED-FIRST: this case fails on `origin/main` — Save is absent and every + * control disabled. + */ + it('stock-boot permission shape (allowRuntimeCreate only) is EDITABLE — the server accepts this write', async () => { + allowOrgOverride = false; + allowRuntimeCreate = true; + renderMatrix(); + await screen.findByText('Account'); + + expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled(); + expect(screen.getByLabelText('a_account Read')).toBeEnabled(); + + // Row bulk-set buttons are live too — `writable` drives all of them. + const row = screen.getByText('Account').closest('tr')!; + for (const name of ['R', 'CRUD', 'All', 'None']) { + expect(within(row).getByRole('button', { name })).toBeEnabled(); + } + + // …and no read-only badge of EITHER wording is claiming otherwise. + expect(screen.queryByText(/OS_METADATA_WRITABLE/)).toBeNull(); + expect(screen.queryByText(/no runtime write channel/)).toBeNull(); + expect(screen.queryByText('Read-only', { exact: true })).toBeNull(); + }); + + it('MUST NOT CHANGE — the package gate still wins over allowRuntimeCreate', async () => { + // The "Studio 维持包级只读" half of the objectstack#5768 ruling: a + // code-defined package stays locked no matter what the type permits. + allowOrgOverride = false; + allowRuntimeCreate = true; + renderMatrix({ readOnly: true }); + await screen.findByText('Account'); + + expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull(); + for (const box of screen.getAllByRole('checkbox')) expect(box).toBeDisabled(); + // …and it is still the PACKAGE that is named as the reason. + const badge = screen.getByText('Read-only', { exact: true }); + expect(badge).toHaveAttribute('title', expect.stringContaining('Read-only package')); + expect(screen.queryByText(/no runtime write channel/)).toBeNull(); + }); + + it('MUST NOT CHANGE — an allowOrgOverride package is byte-identical to before', async () => { + allowOrgOverride = true; + allowRuntimeCreate = false; + renderMatrix(); + await screen.findByText('Account'); + + expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled(); + expect(screen.getByLabelText('a_account Read')).toBeEnabled(); + expect(screen.queryByText('Read-only', { exact: true })).toBeNull(); + expect(screen.queryByText(/no runtime write channel/)).toBeNull(); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.readonlyHeaderBadge.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.readonlyHeaderBadge.test.tsx index e91edb4704..509967e492 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.readonlyHeaderBadge.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.readonlyHeaderBadge.test.tsx @@ -15,9 +15,9 @@ * they disagreed, because they read different inputs: * * • the CONTROLS read `!!resolved.allowOrgOverride && !readOnly` — the type - * registry AND the host package gate, which is the right answer; - * • the BADGE read `entry.allowOrgOverride` alone. `permission` is one of the - * 15 overlay-allowed types, so that flag is true and the badge said + * registry AND the host package gate; + * • the BADGE read `entry.allowOrgOverride` alone. `permission` was then one + * of the 15 overlay-allowed types, so that flag was true and the badge said * "writable" no matter which package you were in. * * This suite pins the display side only. The gating assertions here are @@ -25,6 +25,31 @@ * read-only intact ("Studio 维持包级只读") pending the per-type A-half review, * so a fix that made anything editable would be the wrong fix even though the * badge assertions would still pass. + * + * ## Amendment — objectui#4446: the CONTROLS were the outlier, not the badge + * + * The paragraph above called the controls' formula "the right answer". That was + * wrong on the type half, and this suite could not see it: every case here + * drives the HOST gate (`readOnly` true/absent) with `allowOrgOverride: true` + * throughout, so it never rendered the one shape where the two disagree. + * + * `WritabilityBadge` is read-only exactly when + * `readOnly || (!allowOrgOverride && !allowRuntimeCreate)`; the controls were + * read-only when `readOnly || !allowOrgOverride`. The gap is precisely + * `!allowOrgOverride && allowRuntimeCreate` — which is the stock-boot + * `permission` shape today (objectstack#6483 set `allowOrgOverride: false` and + * deliberately kept `allowRuntimeCreate: true`). There the badge said + * "create-only" over 207 dead checkboxes. + * + * So the badge already consulted the full disjunction and the controls did not. + * Fixing the controls (`allowOrgOverride || allowRuntimeCreate`) makes the two + * predicates byte-identical rather than merely closer — the convergence is + * pinned below, across all four states, so neither side can drift again. + * + * The host gate is UNTOUCHED and still dominant, so "Studio 维持包级只读" holds + * exactly as before: a read-only package locks this screen whatever the type + * permits. What changed is the type half, and only in the direction the server + * itself already allows (it refuses only when BOTH flags are false). */ import '@testing-library/jest-dom/vitest'; @@ -61,20 +86,31 @@ function makeClient() { } // The type registry says this type IS overlay-allowed — the exact condition the -// defect needed. `permission` really is in the server's overlay-allowed list. +// #4036 defect needed. (`permission` was in the server's overlay-allowed list +// when this suite was written; objectstack#6483 has since flipped it to +// `allowOrgOverride: false` + `allowRuntimeCreate: true`. These cases keep the +// original shape on purpose — it is the must-not-change control for #4446 — +// while the convergence describe at the bottom drives the current one.) +let typeFlags: { allowOrgOverride?: boolean; allowRuntimeCreate?: boolean } = { + allowOrgOverride: true, +}; + vi.mock('./useMetadata', () => ({ useMetadataClient: () => clientImpl, useMetadataTypes: () => ({ loading: false, error: null, - entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }], + entries: [{ type: 'permission', label: 'Permission', ...typeFlags }], }), })); vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null })); import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; -afterEach(cleanup); +afterEach(() => { + cleanup(); + typeFlags = { allowOrgOverride: true }; +}); async function renderMatrix(props?: { readOnly?: boolean }) { clientImpl = makeClient(); @@ -151,3 +187,70 @@ describe('PermissionMatrixEditPage — header writability badge vs the package g expect(screen.getByLabelText('a_account Read')).toBeEnabled(); }); }); + +/** + * The convergence itself (objectui#4446). One table, four states, and in each + * one the question "is the header badge read-only?" and the question "are the + * controls locked?" must get the SAME answer. Pre-fix row 3 is the divergence: + * badge "create-only" (not read-only) over disabled controls with no Save. + */ +describe('PermissionMatrixEditPage — badge and controls read ONE predicate (#4446)', () => { + const cases: Array<{ + label: string; + flags: { allowOrgOverride?: boolean; allowRuntimeCreate?: boolean }; + readOnly?: boolean; + expectWritable: boolean; + }> = [ + { + label: 'read-only package dominates both flags', + flags: { allowOrgOverride: true, allowRuntimeCreate: true }, + readOnly: true, + expectWritable: false, + }, + { + label: 'overlay-allowed type in a writable package', + flags: { allowOrgOverride: true, allowRuntimeCreate: false }, + expectWritable: true, + }, + { + // The stock-boot `permission` shape — the #4446 defect. + label: 'runtime-creatable-only type in a writable package', + flags: { allowOrgOverride: false, allowRuntimeCreate: true }, + expectWritable: true, + }, + { + label: 'no write channel at all', + flags: { allowOrgOverride: false, allowRuntimeCreate: false }, + expectWritable: false, + }, + ]; + + for (const c of cases) { + it(`${c.label} → ${c.expectWritable ? 'writable' : 'locked'}, and both renderings agree`, async () => { + typeFlags = c.flags; + await renderMatrix(c.readOnly ? { readOnly: true } : undefined); + + // CONTROLS side. + const save = screen.queryByRole('button', { name: /^Save$/ }); + const readBox = screen.getByLabelText('a_account Read'); + + // BADGE side — the header slot, addressed structurally (see above). + const badgeSaysReadOnly = headerBadgeTexts().includes('read-only'); + + if (c.expectWritable) { + expect(save).not.toBeNull(); + expect(save).toBeEnabled(); + expect(readBox).toBeEnabled(); + expect(badgeSaysReadOnly).toBe(false); + } else { + expect(save).toBeNull(); + expect(readBox).toBeDisabled(); + expect(badgeSaysReadOnly).toBe(true); + } + + // The invariant, stated as the equality it is: the header never claims a + // writability the controls do not deliver, and never withholds one they do. + expect(badgeSaysReadOnly).toBe(!c.expectWritable); + }); + } +}); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 198cc1636b..e7fb3ffa1f 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -73,7 +73,6 @@ import { CapabilityMultiSelectField, parseCapabilityNames } from '@object-ui/fie import { PageShell } from './PageShell'; import { HistoryPanel } from './ResourceHistoryPage'; import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from './useMetadata'; -import { resolveResourceConfig } from './registry'; import { t as translate, useMetadataLocale } from './i18n'; import { PermissionAdvancedFacets } from './PermissionAdvancedFacets'; import { errorCodeIs } from '@object-ui/types'; @@ -211,12 +210,44 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, const adapter = useAdapter(); const { entries } = useMetadataTypes(client); const entry: RichMetadataTypeEntry | undefined = entries.find((t) => t.type === type); - const resolved = resolveResourceConfig(type, entry); - // Two independent read-only gates: the metadata TYPE may forbid org - // overrides (allowOrgOverride), and the HOST may pass a package-level - // `readOnly` (read-only package in the Studio Access pillar). Either one - // must lock every authoring affordance below. - const writable = !!resolved.allowOrgOverride && !readOnly; + // Two independent read-only gates, and each reads the flag that actually + // governs it (objectui#4446): + // + // • TYPE gate — the metadata type must offer SOME runtime write channel. + // That is the DISJUNCTION `allowOrgOverride || allowRuntimeCreate`, not + // `allowOrgOverride` alone: those are two different doors, and this + // editor's Save goes through the second one. `allowOrgOverride` is + // permission to OVERLAY a code-shipped item per org; `allowRuntimeCreate` + // is permission to author an item at runtime — which is what a save under + // a `packageId` does (`mode: 'draft'` + `packageId`, ADR-0086 P0/P2). + // The server's own gate is that same disjunction: `saveMetaItem` and + // `promoteDraftForPublish` refuse only when BOTH are false + // (`!isOverlayAllowed && !isRuntimeCreateAllowed`, metadata-protocol + // `protocol.ts`). Gating on `allowOrgOverride` alone therefore locked a + // surface the server accepts: `permission` is `allowOrgOverride: false` + // (ADR-0005 forbids per-org overlay of a packaged permission set — silent + // privilege drift) but `allowRuntimeCreate: true`, and objectstack#6483 + // kept that second door open on purpose ("Runtime-created sets … ride + // `allowRuntimeCreate` (still `true`) and keep working"). + // `useMetadata.ts` states the convention on the field itself: "UI + // affordances ('+ New', Save, Delete on DB-only items) should activate + // when either flag is true" — DirectoryPage:171/175, EmbeddedItemEditor:93 + // and ResourceEditPage:1332 all already read it that way. Read the raw + // server `entry` like they do; `resolveResourceConfig` forwards + // `allowOrgOverride` ONLY, so a `resolved.allowRuntimeCreate` would be + // silently `undefined`. + // + // • 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; const locale = useMetadataLocale(); const t = React.useCallback((k: string) => translate(k, locale), [locale]); const OBJECT_ACTIONS = React.useMemo(() => getObjectActions(locale), [locale]); @@ -635,9 +666,16 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, stats={stats} embedded={embedded} // The header badge must report the gate that actually governs this - // screen. `permission` is overlay-allowed in the registry, so without - // this the hero rendered "writable" while every control below it was - // disabled by the package gate (objectui#4036). + // screen: without this the hero rendered "writable" while every control + // below it was disabled by the package gate (objectui#4036). + // + // Still the HOST gate only, deliberately — `WritabilityBadge` reads the + // type flags itself, and its own read-only condition (`readOnly || + // (!allowOrgOverride && !allowRuntimeCreate)`) is now exactly `!writable` + // above. Passing `!writable` here instead would collapse that to one + // input but make a TYPE-gate lock claim the PACKAGE as its reason, since + // this branch's tooltip is `engine.studio.pkg.readonlyHint` — trading the + // divergence for a fresh lie (objectui#4446). readOnly={readOnly} actions={ <> @@ -712,14 +750,27 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, {t('perm.basics.editHint')} )} {!writable && ( - // Same badge slot, two distinct reasons: a read-only PACKAGE - // (host gate — mirror the top-bar wording so the screen is not - // self-contradictory) vs. metadata writes disabled environment- - // wide (type gate). + // Same badge slot, two distinct reasons, and each names the gate + // that ACTUALLY tripped (objectui#4446): + // + // • host gate — a read-only PACKAGE; mirror the top-bar wording + // so the screen is not self-contradictory. + // • 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 + // enabled", which blamed a deployment env var for what is a + // per-type registry declaration. That wording had NO reachable + // honest case: `OS_METADATA_WRITABLE` does not sit beside + // `allowOrgOverride`, it FLIPS it — `getMetaTypes` emits + // `allowOrgOverride: base.allowOrgOverride || isEnvOverridden` + // — so whenever the hatch is on for this type the surface is + // writable and this badge does not render. The env var is a + // documented REMEDY (it appears in the server's own 403 text), + // never the cause, so it belongs in the hint, not the label. {readOnly ? t('engine.studio.pkg.readonly') : 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 90d3086086..2d76b24d60 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1182,7 +1182,14 @@ const ENGINE_STRINGS_EN: Record = { 'perm.admin.assignableSets': 'Assignable permission sets', 'perm.admin.noSets': 'No permission sets loaded.', 'perm.loading': 'Loading permission set {name}…', - 'perm.readOnly': 'Read-only (OS_METADATA_WRITABLE not enabled)', + // objectui#4446 — names the gate that actually tripped. The old wording + // ("OS_METADATA_WRITABLE not enabled") blamed a deployment env var for a + // per-type registry declaration, and had no reachable honest case: the env + // var FLIPS `allowOrgOverride` to true, so whenever it is on for this type + // the surface is writable and this badge never renders. + '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.', // Designer wrapper 'designer.unsavedChanges': 'Unsaved changes', 'designer.editingOverlay': 'Editing overlay', @@ -2951,7 +2958,10 @@ const ENGINE_STRINGS_ZH: Record = { 'perm.admin.assignableSets': '可分配权限集', 'perm.admin.noSets': '未加载权限集。', 'perm.loading': '加载权限集 {name}…', - 'perm.readOnly': '只读(OS_METADATA_WRITABLE 未启用)', + // objectui#4446 — 见 EN 表同键注释:旧文案把「每类型注册表声明」说成「部署环境变量未启用」。 + 'perm.readOnly': '只读(该元数据类型没有运行时写入通道)', + 'perm.readOnly.hint': + '元数据类型注册表对该类型声明 allowOrgOverride 与 allowRuntimeCreate 均为 false,平台不接受它的任何运行时写入。请修改源工件后重新部署,或由运维启用有文档记载的 OS_METADATA_WRITABLE 逃生阀。', // Designer wrapper 'designer.unsavedChanges': '未保存的修改', 'designer.editingOverlay': '编辑覆盖层',