Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/metadata-lock-state-5024.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<div>` 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<typeof import('./useMetadata')>();
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(
<MemoryRouter initialEntries={['/metadata/object/showcase_account']}>
<MetadataResourceEditPage type="object" name="showcase_account" />
</MemoryRouter>,
);
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('');
});
});
63 changes: 59 additions & 4 deletions packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Exclude<MetadataLockState, 'none'>, 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 `<div>` 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<MetadataLockState, 'none'>], 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
Expand Down Expand Up @@ -1990,10 +2047,8 @@ function MetadataResourceEditPageImpl({
<div className="text-xs text-amber-900 border border-amber-300/70 bg-amber-50/70 rounded-md px-3 py-2.5 dark:text-amber-200 dark:border-amber-700/40 dark:bg-amber-950/20 flex items-start gap-2.5">
<Lock className="h-3.5 w-3.5 mt-0.5 shrink-0 opacity-80" />
<div className="flex-1 min-w-0">
<div className="font-medium">
{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)}
<div className="font-medium" data-testid="lock-banner-title">
{lockBannerTitle(layered?.lock, locale)}
</div>
{lockReason && <div className="mt-0.5 opacity-90">{lockReason}</div>}
{layered?.lockDocsUrl && (
Expand Down
25 changes: 18 additions & 7 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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',
Expand Down Expand Up @@ -2101,6 +2108,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.edit.lockFull': '该元数据已锁定,不可编辑或删除。',
'engine.edit.lockNoOverlay': '该元数据已锁定,不可编辑。',
'engine.edit.lockNoDelete': '该元数据已锁定,不可删除。',
'engine.edit.lockUnknown':
'该元数据已锁定,但当前控制台无法识别锁状态“{state}”——它可能来自更新版本的服务端。部分操作将被阻止。',
'engine.edit.history': '历史',
'engine.edit.auditTab': '审计日志',
'engine.edit.auditCount': '条记录',
Expand Down Expand Up @@ -4244,13 +4253,15 @@ const LAYER_SCOPE_ZH: Record<NonNullable<MetadataOverlayScope>, 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
Expand Down
1 change: 1 addition & 0 deletions packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4687,6 +4687,7 @@ export type {
MetadataError,
MetadataValidationIssue,
MetadataLayered,
MetadataLockState,
MetadataOverlayScope,
MetadataReference,
MetadataDiagnostics,
Expand Down
33 changes: 31 additions & 2 deletions packages/data-objectstack/src/metadata-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -295,7 +324,7 @@ export interface MetadataLayered<T = unknown> {
_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. */
Expand Down Expand Up @@ -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). */
Expand Down
Loading