diff --git a/.changeset/metadata-lock-state-5024.md b/.changeset/metadata-lock-state-5024.md
new file mode 100644
index 000000000..785458cbf
--- /dev/null
+++ b/.changeset/metadata-lock-state-5024.md
@@ -0,0 +1,25 @@
+---
+'@object-ui/data-objectstack': patch
+'@object-ui/app-shell': patch
+---
+
+The metadata lock banner can no longer render an amber, padlocked box with no
+title, and the ADR-0010 §3.6 lock vocabulary is declared once instead of three
+times (objectui#5024).
+
+`MetadataLayered.lock` and `MetadataAuditEntry.lockState` each spelled the four
+states out by hand, 42 lines apart in one file, compared by no gate. They are now
+one exported `MetadataLockState` — derived from `GetMetaItemLayeredResponseSchema`'s
+`z.enum` in `@objectstack/spec`, which already owns this vocabulary, so the copies
+were restating a schema rather than filling a gap.
+
+The user-visible half is the banner. Its title was three independent `&&` branches
+with no fallback, while the switch that opens the banner is true for any non-`none`
+value — so a lock state outside the four opened the box and left the headline
+empty. That is reachable without a fifth state ever being added here:
+`MetadataClient.layered()` casts the wire value through unchecked, so a newer
+server reaches this banner as-is. Measured, not assumed — feeding `no-publish`
+through the page rendered the padlock, the border and an empty title. The title is
+now a keyed lookup with a loud fallback that names the unrecognised token, so a
+fifth state fails `type-check` here and, if one arrives from a server anyway, the
+operator reads a sentence instead of a blank box.
diff --git a/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx b/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx
index 390ce1d98..aa47fd02b 100644
--- a/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx
+++ b/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx
@@ -19,10 +19,13 @@
*
* The state list below is bound to `MetadataAuditEntry['lockState']` itself
* rather than spelled freely, so a fifth lock state is covered by these cases
- * the moment the union gains it. Unlike objectui#4982's overlay scopes there is
- * no `@objectstack/spec` enum to read at runtime — this union is hand-written in
- * `packages/data-objectstack` — hence the `satisfies`-checked key trick instead
- * of a `.options` array. (The compile-time half of the coverage is
+ * the moment the vocabulary gains it. That field is `MetadataLockState` since
+ * objectui#5024, derived from `GetMetaItemLayeredResponseSchema`'s `z.enum` —
+ * so unlike what this comment first claimed, objectui#4982's overlay scopes are
+ * NOT the odd one out: the spec declares this enum too (17.1.0). The
+ * `satisfies`-checked key trick is kept over a runtime `.options` array because
+ * it fails at `type-check` rather than at run time, which is the earlier of the
+ * two; nothing about the derivation forces the choice either way. (The compile-time half of the coverage is
* `LOCK_STATE_ZH` in `./i18n`, whose key type is that union: a new state with no
* zh-CN label fails `type-check` before it can reach the column.)
*/
diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.lockBanner.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.lockBanner.test.tsx
new file mode 100644
index 000000000..54a2b8ffb
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.lockBanner.test.tsx
@@ -0,0 +1,162 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The lock banner must never render a headline-less amber box — objectui#5024.
+ *
+ * ## The defect
+ *
+ * The banner's title was three independent `&&` branches with no `else` and no
+ * fallback:
+ *
+ * {layered?.lock === 'full' && t('engine.edit.lockFull', locale)}
+ * {layered?.lock === 'no-overlay' && t('engine.edit.lockNoOverlay', locale)}
+ * {layered?.lock === 'no-delete' && t('engine.edit.lockNoDelete', locale)}
+ *
+ * while the switch that OPENS the banner is `layered?.lock && lock !== 'none'`
+ * — true for *any* non-`none` value. So a lock state outside the ADR-0010 §3.6
+ * four opens the amber box, draws the padlock and the border, and leaves the
+ * title `
` empty: a locked-looking banner that never says what is locked
+ * or why.
+ *
+ * ## Why this is reachable today, not only "the day a fifth state lands"
+ *
+ * The card framed it as dormant, on the reading that both hand-written unions
+ * list the same four values. The unions are not the gate. `MetadataClient.
+ * layered()` passes the wire value through with an unchecked cast —
+ *
+ * ...(body.lock !== undefined ? { lock: body.lock as MetadataLayered['lock'] } : {}),
+ *
+ * — over a `res.json()` body. There is no Zod parse, no allowlist, no default
+ * on this path. The union constrains what this repo may *write*; it constrains
+ * nothing about what a server may *send*. A backend that grows a fifth state
+ * reaches this banner with zero code change here, which is why the fix has to
+ * be a runtime fallback and not only a compile-time exhaustiveness check: a
+ * `satisfies` assertion is satisfied by the current four either way and would
+ * have left the blank banner exactly as it was.
+ *
+ * ## What this suite pins
+ *
+ * The four known states keep their existing sentences (this half passes before
+ * the fix — it is the control that says a red fifth-value case is the defect
+ * and not a broken harness), and an out-of-vocabulary state renders a loud,
+ * non-empty fallback that also surfaces the raw token so the state a server
+ * actually sent is diagnosable from the screen.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+const objectDef = {
+ name: 'showcase_account',
+ label: 'Account',
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
+};
+
+const layeredImpl = { current: vi.fn() };
+
+const mockClient = {
+ list: vi.fn(async () => []),
+ listDrafts: vi.fn(async () => []),
+ get: vi.fn(async () => null),
+ getDraft: vi.fn(async () => null),
+ references: vi.fn(async () => []),
+ layered: vi.fn(async (...args: unknown[]) => layeredImpl.current(...args)),
+};
+
+vi.mock('./useMetadata', async (importOriginal) => {
+ const mod = await importOriginal
();
+ return {
+ ...mod,
+ useMetadataClient: () => mockClient,
+ useMetadataTypes: () => ({
+ loading: false,
+ error: null,
+ entries: [
+ {
+ type: 'object',
+ name: 'object',
+ label: 'Object',
+ allowOrgOverride: false,
+ allowRuntimeCreate: true,
+ schema: {
+ type: 'object',
+ properties: { name: { type: 'string' }, label: { type: 'string' } },
+ },
+ },
+ ],
+ }),
+ };
+});
+
+import { MetadataResourceEditPage } from './ResourceEditPage';
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+/**
+ * Mount the editor over a layered envelope carrying `lock`. `provenance: 'org'`
+ * keeps the separate installed-package notice out of the way so the assertions
+ * below read the LOCK banner and nothing else.
+ */
+async function renderWithLock(lock: unknown) {
+ layeredImpl.current = vi.fn(async () => ({
+ code: { ...objectDef, _packageId: 'com.example.base', _provenance: 'org' },
+ overlay: null,
+ overlayScope: null,
+ effective: objectDef,
+ provenance: 'org',
+ packageId: 'com.example.base',
+ editable: true,
+ lock,
+ }));
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(mockClient.layered).toHaveBeenCalled());
+ return waitFor(() => {
+ const el = screen.queryByTestId('lock-banner-title');
+ expect(el).not.toBeNull();
+ return el as HTMLElement;
+ });
+}
+
+describe('ResourceEditPage lock banner — every state that opens the banner also titles it (#5024)', () => {
+ // The control half: these pass before the fix as well as after. If the
+ // out-of-vocabulary case below goes red while these stay green, the red is
+ // the defect rather than a harness that cannot mount the page.
+ it.each([
+ ['full', 'This item is locked and cannot be edited or deleted.'],
+ ['no-overlay', 'This item is locked and cannot be edited.'],
+ ['no-delete', 'This item is locked and cannot be deleted.'],
+ ])('known state %s keeps its existing sentence', async (lock, sentence) => {
+ const el = await renderWithLock(lock);
+ expect(el.textContent?.trim()).toBe(sentence);
+ });
+
+ it('an out-of-vocabulary state from the wire still gets a title', async () => {
+ // The whole defect in one assertion: pre-fix this element exists (the
+ // banner opened) and is EMPTY.
+ const el = await renderWithLock('no-publish');
+ expect(el.textContent?.trim()).not.toBe('');
+ });
+
+ it('an out-of-vocabulary state names the raw token it could not read', async () => {
+ // "Loudly", per the triage ruling: a generic "this is locked" that hides
+ // WHICH state arrived would leave an operator with nothing to report.
+ const el = await renderWithLock('no-publish');
+ expect(el.textContent).toContain('no-publish');
+ });
+
+ it('a non-string lock value cannot crash or blank the banner', async () => {
+ // `layered()` casts whatever JSON held; a malformed body is the same class
+ // of unchecked input as an unknown state name.
+ const el = await renderWithLock(42);
+ expect(el.textContent?.trim()).not.toBe('');
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
index 48b5bb08a..542e3993e 100644
--- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
+++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
@@ -82,6 +82,7 @@ import {
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
import type {
MetadataLayered,
+ MetadataLockState,
MetadataReference,
} from '@object-ui/data-objectstack';
import { PageShell } from './PageShell.js';
@@ -127,6 +128,62 @@ import { describeIssuePath } from './issuePath.js';
import { buildCreateModeBody } from './createBody.js';
import { errorCodeIs, errorCodeIsAnyOf } from '@object-ui/types';
+/**
+ * ADR-0010 §3.6 lock state -> the lock banner's headline sentence.
+ *
+ * Keyed on `MetadataLockState` — which derives from `packages/spec`'s own
+ * `z.enum` — so a fifth state added upstream fails `type-check` HERE, naming
+ * the label that is missing. That is the strictness the audit panel's
+ * `LOCK_STATE_ZH` has had since objectui#5004 and this banner did not: its
+ * title used to be three independent `&&` branches with no `else`.
+ *
+ * `Exclude<…, 'none'>` because `none` never banners — `isLocked` gates the
+ * whole box on `lock && lock !== 'none'`. Typing the record over exactly the
+ * states that CAN reach the screen keeps the two rules from drifting apart,
+ * and still fails on a state added to the union.
+ */
+const LOCK_BANNER_TITLE_KEY: Record, string> = {
+ 'no-overlay': 'engine.edit.lockNoOverlay',
+ 'no-delete': 'engine.edit.lockNoDelete',
+ full: 'engine.edit.lockFull',
+};
+
+/**
+ * The banner's headline for whatever `lock` ACTUALLY arrived — including a
+ * value the lookup above has never heard of (objectui#5024).
+ *
+ * The compile-time half cannot be the whole fix. `MetadataLockState` types what
+ * this repo may WRITE; it constrains nothing about what a server may SEND,
+ * because `MetadataClient.layered()` casts the wire value in unchecked:
+ *
+ * ...(body.lock !== undefined ? { lock: body.lock as MetadataLayered['lock'] } : {}),
+ *
+ * over a raw `res.json()` body — no parse, no allowlist, no default. So a
+ * back end that grows a fifth state reaches this banner with no code change
+ * here at all. Measured rather than assumed: feeding `no-publish` through this
+ * page opened the amber box, drew the padlock and the border, and left the
+ * title `` empty. An exhaustive `satisfies` alone would have type-checked
+ * green over that exact render.
+ *
+ * Hence a sentence for the unrecognised value, carrying the raw token: the
+ * operator who meets this is the only person able to report which state their
+ * server actually sent, and a generic "this is locked" would take that away.
+ * `String(lock)` rather than a cast — the same unchecked path can hand us a
+ * number or an object, and this must not throw on the way to explaining itself.
+ */
+function lockBannerTitle(
+ lock: MetadataLayered['lock'],
+ locale: string | undefined,
+): string {
+ if (
+ typeof lock === 'string' &&
+ Object.prototype.hasOwnProperty.call(LOCK_BANNER_TITLE_KEY, lock)
+ ) {
+ return t(LOCK_BANNER_TITLE_KEY[lock as Exclude
], locale);
+ }
+ return tFormat('engine.edit.lockUnknown', locale, { state: String(lock) });
+}
+
/**
* Metadata types whose canvas IS the primary create-time authoring
* surface, so we render the preview/inspector split during create
@@ -1990,10 +2047,8 @@ function MetadataResourceEditPageImpl({
-
- {layered?.lock === 'full' && t('engine.edit.lockFull', locale)}
- {layered?.lock === 'no-overlay' && t('engine.edit.lockNoOverlay', locale)}
- {layered?.lock === 'no-delete' && t('engine.edit.lockNoDelete', locale)}
+
+ {lockBannerTitle(layered?.lock, locale)}
{lockReason &&
{lockReason}
}
{layered?.lockDocsUrl && (
diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts
index fe14f40c1..a6f983a42 100644
--- a/packages/app-shell/src/views/metadata-admin/i18n.ts
+++ b/packages/app-shell/src/views/metadata-admin/i18n.ts
@@ -282,6 +282,13 @@ const ENGINE_STRINGS_EN: Record
= {
'engine.edit.lockFull': 'This item is locked and cannot be edited or deleted.',
'engine.edit.lockNoOverlay': 'This item is locked and cannot be edited.',
'engine.edit.lockNoDelete': 'This item is locked and cannot be deleted.',
+ // objectui#5024 — the headline for a lock state this console has no
+ // sentence for. Reachable without any change here: `layered()` casts the
+ // wire value through unchecked, so a newer server can send a fifth state
+ // today. Names the raw token because the operator who sees it is the only
+ // one who can report which state their server sent.
+ 'engine.edit.lockUnknown':
+ 'This item is locked, but this console does not recognise the lock state \u2018{state}\u2019 — it may come from a newer server. Some operations will be blocked.',
'engine.edit.history': 'History',
'engine.edit.auditTab': 'Audit log',
'engine.edit.auditCount': 'events',
@@ -2101,6 +2108,8 @@ const ENGINE_STRINGS_ZH: Record = {
'engine.edit.lockFull': '该元数据已锁定,不可编辑或删除。',
'engine.edit.lockNoOverlay': '该元数据已锁定,不可编辑。',
'engine.edit.lockNoDelete': '该元数据已锁定,不可删除。',
+ 'engine.edit.lockUnknown':
+ '该元数据已锁定,但当前控制台无法识别锁状态“{state}”——它可能来自更新版本的服务端。部分操作将被阻止。',
'engine.edit.history': '历史',
'engine.edit.auditTab': '审计日志',
'engine.edit.auditCount': '条记录',
@@ -4244,13 +4253,15 @@ const LAYER_SCOPE_ZH: Record, string> = {
* zh-CN labels for the ADR-0010 §3.6 four-state metadata lock, keyed by the
* producer field the only consumer actually renders.
*
- * The key type is `MetadataAuditEntry['lockState']` — the hand-written union in
- * `packages/data-objectstack/src/metadata-client.ts`, not a `@objectstack/spec`
- * enum, because this repo owns that union today. (Whether the spec should own
- * the lock vocabulary is a separate question; it is deliberately not answered
- * here.) Binding the keys means a fifth lock state added to the union stops this
- * record from compiling and names the label that is missing, instead of the
- * column silently shipping a raw English token.
+ * The key type is `MetadataAuditEntry['lockState']`, which since objectui#5024
+ * is `MetadataLockState` — derived from `GetMetaItemLayeredResponseSchema`'s
+ * `z.enum` in `@objectstack/spec`. It was a hand-written union in
+ * `packages/data-objectstack/src/metadata-client.ts` when this comment was
+ * first written, on the belief that the spec had no enum to derive from; the
+ * spec does declare one (17.1.0), so the question of ownership this comment
+ * used to leave open is answered upstream, not here. Binding the keys means a
+ * fifth lock state stops this record from compiling and names the label that is
+ * missing, instead of the column silently shipping a raw English token.
*
* That silent path is objectui#5004, and it was total rather than partial: the
* table used to hold `draft` / `locked` / `published` / `none` — a draft-status
diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts
index ff036cc22..9f23a64ab 100644
--- a/packages/data-objectstack/src/index.ts
+++ b/packages/data-objectstack/src/index.ts
@@ -4687,6 +4687,7 @@ export type {
MetadataError,
MetadataValidationIssue,
MetadataLayered,
+ MetadataLockState,
MetadataOverlayScope,
MetadataReference,
MetadataDiagnostics,
diff --git a/packages/data-objectstack/src/metadata-client.ts b/packages/data-objectstack/src/metadata-client.ts
index 64e866fb3..06c96afd2 100644
--- a/packages/data-objectstack/src/metadata-client.ts
+++ b/packages/data-objectstack/src/metadata-client.ts
@@ -265,6 +265,35 @@ export interface MetadataDeleteOptions extends MetadataClientSaveOptions {
*/
export type MetadataOverlayScope = GetMetaItemLayeredResponse['overlayScope'];
+/**
+ * ADR-0010 §3.6 — the four-state metadata protection lock
+ * (`none` / `no-overlay` / `no-delete` / `full`), read from
+ * `GetMetaItemLayeredResponseSchema`'s own `z.enum` rather than spelled out
+ * again here (objectui#5024).
+ *
+ * It used to be written out twice in this file, 42 lines apart: once as
+ * {@link MetadataLayered.lock} (optional) and once as
+ * {@link MetadataAuditEntry.lockState} (nullable), identical in every other
+ * respect and compared by no gate. A fifth state added to one would have left
+ * the other compiling — the same "declared N times, diffed by nothing" failure
+ * objectui#4972 and objectui#4984 record for other vocabularies.
+ *
+ * Deriving beats a local alias the two merely share. The producer of these
+ * values is the framework, and `packages/spec` already declares the vocabulary,
+ * so the copies were restating a schema that existed rather than filling a gap.
+ * The card that reported this recorded the opposite — "`@objectstack/spec` 也没
+ * 有对应的 `z.enum` 可派生" — and so did the audit panel's neighbouring comment;
+ * both predate the enum, which ships in `@objectstack/spec` 17.1.0. This is the
+ * same treatment {@link MetadataOverlayScope} above already gets, and it closes
+ * the cross-repo half of the drift, not just the in-repo half.
+ *
+ * ⚠️ This types what this repo may WRITE. It does NOT constrain what a server
+ * may SEND: {@link MetadataClient.layered} casts the wire value through
+ * unchecked, so every reader must still handle a value outside these four. The
+ * lock banner in `ResourceEditPage` is the worked example.
+ */
+export type MetadataLockState = GetMetaItemLayeredResponse['lock'];
+
/**
* Layered view of a metadata item — the body of
* `GET /meta/:type/:name/layers` (`GetMetaItemLayeredResponseSchema`).
@@ -295,7 +324,7 @@ export interface MetadataLayered {
_diagnostics?: MetadataDiagnostics;
// ── ADR-0010 Phase 1 — protection envelope ──
/** 4-state lock: `none` / `no-overlay` / `no-delete` / `full`. */
- lock?: 'none' | 'no-overlay' | 'no-delete' | 'full';
+ lock?: MetadataLockState;
/** Human-readable reason for the lock (tooltip text). */
lockReason?: string;
/** Which layer set the lock: artifact / package / overlay / env-forced. */
@@ -337,7 +366,7 @@ export interface MetadataAuditEntry {
/** Machine-readable reason code (`item_locked`, `ok`, …). */
code: string;
/** Effective lock at the moment of the attempt. */
- lockState: 'none' | 'no-overlay' | 'no-delete' | 'full' | null;
+ lockState: MetadataLockState | null;
/** True when admin forced the write through despite the lock. */
lockOverridden: boolean;
/** Request-id for trace correlation (if propagated). */