diff --git a/.changeset/27279-external-object-field-marks.md b/.changeset/27279-external-object-field-marks.md
new file mode 100644
index 00000000000..cec12cff9ca
--- /dev/null
+++ b/.changeset/27279-external-object-field-marks.md
@@ -0,0 +1,15 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/i18n': patch
+---
+
+feat(studio): an external object's fields show their datasource and write state, and the object designer offers no control that would reshape the remote table
+
+Clause-②: no
+Metadata-protocol: none
+
+An object that carries an `external` binding (ADR-0015) is never a DDL target: its table belongs to the remote database. The object designer now says so instead of offering edits the platform cannot carry out.
+
+- **Every field is marked.** On the field canvas and in the field inspector, each field of an external object carries a mark such as "From plan_gantt · Read-only", and a note above the canvas names the datasource. Read-only is settled from the object alone when it does not set `external.writable: true`. When it does, the datasource is read for `external.allowWrites`: both set reads "Writable", otherwise "Read-only", and a datasource that cannot be read reads "Writes unconfirmed" rather than a guess.
+- **No DDL controls.** On an external object the canvas offers no "Add field" (bottom bar, empty state, section header) and its bulk bar has no Delete. The field inspector disables the API name and the Type picker and offers no Duplicate and no Remove. In the Studio Data pillar, the Records grid's "+" column and the Form toolbar's "Add field" are not offered, and a note above the grid or form names the datasource. Label, section, order and the other display settings stay editable.
+- **Strings.** Six new `designer.external.*` rows in the `studio` namespace of `@object-ui/i18n`, in English and Chinese.
diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx
index eec70145449..d43cdfb3889 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx
@@ -102,6 +102,11 @@ import {
type FieldTypeId,
} from '../previews/field-types.js';
import { FieldTypePicker } from './FieldTypePicker.js';
+import {
+ ExternalFieldBadge,
+ readExternalBinding,
+ useExternalWriteState,
+} from '../previews/external-object.js';
import { CelPredicateField } from '../CelPredicateField.js';
import type { CelLintIssue } from '../celAuthoring.js';
import { t, tFormat, type SupportedLocale } from '../i18n.js';
@@ -664,6 +669,13 @@ export function ObjectFieldInspector({
/** The object this field belongs to — `draft` is the object document. */
const objectName = String(draft.name ?? '');
+ // ADR-0015 — on an external object the field is a remote column: no rename,
+ // retype, duplicate or remove (each would reshape the remote table), and the
+ // field says where it comes from. A hook, so above the `!entry` return.
+ const external = readExternalBinding(draft);
+ const externalWriteState = useExternalWriteState(external);
+ const ddlLocked = readOnly || !!external;
+
/* ─── Blocking CEL verdicts → the host's Save gate (objectui#4306) ─────
*
* Mirrors the shape PermissionAdvancedFacets uses for its per-clause map:
@@ -875,7 +887,7 @@ export function ObjectFieldInspector({
// since two separate writeView() calls from the same stale `entry` closure
// would have the second clobber the first.
const deriveNameFor = (label: string): string | null => {
- if (readOnly) return null;
+ if (ddlLocked) return null;
const owner = recordedOwner();
// The author's own value wins outright — including one that happens to be
// SHAPED like a placeholder (objectui#7615).
@@ -1012,7 +1024,7 @@ export function ObjectFieldInspector({
const headerActions = (
- {!readOnly && (
+ {!ddlLocked && (
);
- const footer = (
+ const footer = external ? undefined : (
{/* Basic */}
+ {external && (
+
+
+
+ )}
@@ -1105,7 +1122,7 @@ export function ObjectFieldInspector({
label={tr('designer.field.type')}
value={type}
onCommit={changeType}
- disabled={readOnly}
+ disabled={ddlLocked}
locale={locale}
/>
diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx
index 141f9826c57..47dacc38a52 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx
@@ -72,6 +72,12 @@ import {
type FieldTypeCategory,
} from './field-types.js';
import { FieldStub } from './FieldStub.js';
+import {
+ ExternalFieldBadge,
+ ExternalObjectNotice,
+ readExternalBinding,
+ useExternalWriteState,
+} from './external-object.js';
import { t, tFormat } from '../i18n.js';
/* ─── locale helpers ─── */
@@ -104,6 +110,12 @@ export function ObjectFormCanvas({
locale,
}: ObjectFormCanvasProps) {
const readOnly = !onPatch;
+ // ADR-0015 — an external object's fields mirror a remote table, so the
+ // controls that would reshape it (add a field, delete fields) are not
+ // offered; each field says where it comes from instead.
+ const external = readExternalBinding(draft);
+ const writeState = useExternalWriteState(external);
+ const canAddFields = !readOnly && !external;
// The "Ask AI" affordances arm the global chat FAB (requestAssistantOpen).
// Hide them when the runtime serves no AI — otherwise they'd dead-end with no
// FAB to open. Same server-pushed signal the FAB itself gates on.
@@ -448,12 +460,13 @@ export function ObjectFormCanvas({
count={multiSel.size}
groups={declaredGroups}
onMoveToGroup={bulkSetGroup}
- onDelete={bulkDelete}
+ onDelete={external ? undefined : bulkDelete}
onClear={clearMulti}
locale={locale}
/>
)}
+ {external &&
}
{!emptyState && (
+
) : (
{groups.map((g) => {
@@ -493,7 +506,7 @@ export function ObjectFormCanvas({
onRename={g.key ? (label) => renameSection(g.key!, label) : undefined}
onRemove={g.key ? () => removeSection(g.key!) : undefined}
onMove={g.key ? (dir) => moveSection(g.key!, dir) : undefined}
- onAddField={readOnly ? undefined : (type) => addField(type, g.key)}
+ onAddField={canAddFields ? (type) => addField(type, g.key) : undefined}
onDropField={readOnly ? undefined : moveToGroup}
>
{g.entries.map((entry) => (
@@ -504,6 +517,11 @@ export function ObjectFormCanvas({
multiSelected={multiSel.has(entry.name)}
diffStatus={statusOf(entry.name)}
changedKeys={changedKeysOf(entry.name)}
+ sourceBadge={
+ external ? (
+
+ ) : undefined
+ }
readOnly={readOnly}
locale={locale}
onClick={(e) => handleRowClick(entry, e)}
@@ -538,7 +556,7 @@ export function ObjectFormCanvas({
{!emptyState && !readOnly && (
-
addField(type)} locale={locale} />
+ {canAddFields && addField(type)} locale={locale} />}
void;
- onDelete: () => void;
+ /** Absent on an external object: deleting a field would drop a remote column. */
+ onDelete?: () => void;
onClear: () => void;
locale?: string;
}) {
@@ -725,15 +744,17 @@ function BulkActionBar({
)}
-
-
- {t('designer.canvas.bulkDelete', locale)}
-
+ {onDelete && (
+
+
+ {t('designer.canvas.bulkDelete', locale)}
+
+ )}
{t('designer.canvas.bulkClear', locale)}
@@ -1001,6 +1022,7 @@ function FieldRow({
multiSelected,
diffStatus,
changedKeys,
+ sourceBadge,
readOnly,
locale,
onClick,
@@ -1014,6 +1036,8 @@ function FieldRow({
/** Review mode: how this field differs from the published baseline. */
diffStatus?: FieldDiffStatus;
changedKeys?: string[];
+ /** ADR-0015 — where an external object's field comes from, and its write state. */
+ sourceBadge?: React.ReactNode;
readOnly: boolean;
locale?: string;
/** Receives the mouse event so the host can branch on Ctrl/⌘/Shift. */
@@ -1200,6 +1224,7 @@ function FieldRow({
{t('designer.canvas.diffChanged', locale)}
)}
+ {sourceBadge}
{typeLabel(meta, locale) ?? typeStr}
diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/external-object.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/external-object.test.tsx
new file mode 100644
index 00000000000..237ba5c8ee2
--- /dev/null
+++ b/ui/packages/app-shell/src/views/metadata-admin/previews/external-object.test.tsx
@@ -0,0 +1,193 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ADR-0015 external objects in the object designer: every field says which
+ * datasource it comes from and whether it can be written, and no control that
+ * would reshape the remote table is offered — add field (canvas, section,
+ * empty state), bulk delete, and in the field inspector rename, retype,
+ * duplicate and remove. A platform-owned object keeps every one of them.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
+import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
+
+const metaGet = vi.fn();
+vi.mock('../useMetadata', () => ({
+ useMetadataClient: () => ({
+ get: metaGet,
+ list: vi.fn().mockResolvedValue([]),
+ listDrafts: vi.fn().mockResolvedValue([]),
+ }),
+}));
+vi.mock('../../../hooks/useAiSurface', () => ({
+ useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }),
+}));
+
+import { ObjectFormCanvas } from './ObjectFormCanvas';
+import { ObjectFieldInspector } from '../inspectors/ObjectFieldInspector';
+import { readExternalBinding } from './external-object';
+
+// The zh rows are read synchronously below; the catalogue is fetched on demand.
+await loadStudioCatalogue('zh');
+
+// A braced body: an arrow returning the mock would hand vitest a cleanup hook to call.
+beforeEach(() => {
+ metaGet.mockReset();
+});
+afterEach(cleanup);
+
+const FIELDS = {
+ task_name: { type: 'text', label: 'Task name' },
+ start_date: { type: 'date', label: 'Start' },
+};
+
+function externalDraft(external: Record = {}) {
+ return { name: 'gantt_task', datasource: 'plan_gantt', external, fields: FIELDS };
+}
+
+describe('readExternalBinding', () => {
+ it('is present exactly when the object carries an external binding', () => {
+ expect(readExternalBinding({ name: 'account', fields: FIELDS })).toBeNull();
+ expect(readExternalBinding(externalDraft())).toEqual({ datasource: 'plan_gantt', writable: false });
+ expect(readExternalBinding(externalDraft({ writable: true }))).toEqual({
+ datasource: 'plan_gantt',
+ writable: true,
+ });
+ });
+});
+
+describe('ObjectFormCanvas — external object', () => {
+ it('marks every field with its datasource and read-only state, and offers no Add field', () => {
+ render( );
+ const badges = screen.getAllByTestId('external-field-badge');
+ // One in the header note, one per field.
+ expect(badges).toHaveLength(3);
+ for (const b of badges) expect(b).toHaveTextContent('From plan_gantt · Read-only');
+ expect(screen.getByTestId('external-object-notice')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Add field/ })).toBeNull();
+ // A writable: false object never asks the datasource.
+ expect(metaGet).not.toHaveBeenCalled();
+ });
+
+ it('reads the mark in Chinese: 来自 plan_gantt · 只读', () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByTestId('external-field-badge')[1]).toHaveTextContent('来自 plan_gantt · 只读');
+ expect(screen.queryByRole('button', { name: /添加字段/ })).toBeNull();
+ });
+
+ it('offers no Add field on an external object with no fields, nor on a section header', () => {
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.queryByRole('button', { name: /Add field/ })).toBeNull();
+ unmount();
+ render(
+ ,
+ );
+ expect(screen.queryByRole('button', { name: 'Add field to this section' })).toBeNull();
+ });
+
+ it('keeps bulk move but drops bulk delete', () => {
+ render( );
+ fireEvent.click(screen.getByText('Task name').closest('[role="button"]')!, { ctrlKey: true });
+ expect(screen.getByText(/1 selected/)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /^Delete/ })).toBeNull();
+ });
+
+ it('a platform-owned object keeps Add field and shows no source mark (control)', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /Add field/ })).toBeInTheDocument();
+ expect(screen.queryByTestId('external-field-badge')).toBeNull();
+ fireEvent.click(screen.getByText('Task name').closest('[role="button"]')!, { ctrlKey: true });
+ expect(screen.getByRole('button', { name: /^Delete/ })).toBeInTheDocument();
+ });
+
+ it('writable: true reads the datasource — allowWrites settles it as Writable', async () => {
+ metaGet.mockResolvedValue({ name: 'plan_gantt', external: { allowWrites: true } });
+ render(
+ ,
+ );
+ expect(metaGet).toHaveBeenCalledWith('datasource', 'plan_gantt');
+ await waitFor(() =>
+ expect(screen.getAllByTestId('external-field-badge')[0]).toHaveTextContent('From plan_gantt · Writable'),
+ );
+ });
+
+ it('writable: true on a datasource without allowWrites is Read-only', async () => {
+ metaGet.mockResolvedValue({ name: 'plan_gantt', external: {} });
+ render(
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getAllByTestId('external-field-badge')[0]).toHaveAttribute('data-write-state', 'read-only'),
+ );
+ });
+
+ it('a datasource read that fails is shown as unconfirmed, never guessed', async () => {
+ metaGet.mockImplementation(async () => {
+ throw new Error('datasource read refused');
+ });
+ render(
+ ,
+ );
+ await waitFor(() => expect(metaGet).toHaveBeenCalled());
+ const badge = screen.getAllByTestId('external-field-badge')[0];
+ expect(badge).toHaveAttribute('data-write-state', 'unconfirmed');
+ expect(badge).toHaveTextContent('From plan_gantt · Writes unconfirmed');
+ });
+});
+
+describe('ObjectFieldInspector — field of an external object', () => {
+ function renderInspector(draft: Record) {
+ return render(
+ ,
+ );
+ }
+
+ it('shows the source mark and offers no rename, retype, duplicate or remove', () => {
+ renderInspector(externalDraft());
+ expect(screen.getByTestId('external-field-badge')).toHaveTextContent('From plan_gantt · Read-only');
+ expect(screen.getByTestId('field-apiname-input')).toBeDisabled();
+ expect(screen.getByRole('combobox', { name: 'Type' })).toBeDisabled();
+ expect(screen.queryByRole('button', { name: 'Duplicate field' })).toBeNull();
+ expect(screen.queryByRole('button', { name: /Remove/ })).toBeNull();
+ // Label is display metadata, not the remote column: it stays editable.
+ expect(screen.getByTestId('field-label-input')).not.toBeDisabled();
+ });
+
+ it('a platform-owned object keeps all four (control)', () => {
+ renderInspector({ name: 'account', fields: FIELDS });
+ expect(screen.queryByTestId('external-field-badge')).toBeNull();
+ expect(screen.getByTestId('field-apiname-input')).not.toBeDisabled();
+ expect(screen.getByRole('combobox', { name: 'Type' })).not.toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Duplicate field' })).toBeInTheDocument();
+ const footer = screen.getByRole('button', { name: /Remove/ });
+ expect(within(footer).getByText(/Task name/)).toBeInTheDocument();
+ });
+});
diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/external-object.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/external-object.tsx
new file mode 100644
index 00000000000..e1c39375dde
--- /dev/null
+++ b/ui/packages/app-shell/src/views/metadata-admin/previews/external-object.tsx
@@ -0,0 +1,147 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * External (federated) objects in the object designer — ADR-0015.
+ *
+ * An object is external exactly when it carries an `external` binding
+ * (`ObjectExternalBindingSchema`, `@objectstack/spec/data`). That is the same
+ * presence test the runtime uses to decide that an object is never a DDL
+ * target (`isFederatedObject` in `@objectstack/objectql`): its table belongs
+ * to the remote database. The designer therefore offers no control that would
+ * change the remote table's shape — add, duplicate, delete, rename or retype a
+ * field — and says where the fields come from instead.
+ *
+ * Writes to an external object need BOTH opt-ins: the datasource's
+ * `external.allowWrites` and the object's own `external.writable` (ADR-0015
+ * §5.3, the engine's write gate). The object alone settles the common case:
+ * without `writable: true` it is read-only whatever the datasource says. Only
+ * when the object opts in is the datasource read, through the same metadata
+ * door the datasource preview reads `external.allowWrites` from; until that
+ * answer arrives, or when it cannot be read, the state is shown as
+ * unconfirmed rather than guessed.
+ */
+
+import * as React from 'react';
+import { Badge } from '@object-ui/components';
+import { DatabaseZap } from 'lucide-react';
+import { useMetadataClient } from '../useMetadata.js';
+import { t, tFormat } from '../i18n.js';
+
+export interface ExternalObjectBinding {
+ /** The object's `datasource` — the federated datasource its table lives in. */
+ datasource: string;
+ /** The object's own write opt-in, `external.writable`. */
+ writable: boolean;
+}
+
+/** `read-only` and `writable` are settled; `unconfirmed` means the datasource half is not known. */
+export type ExternalWriteState = 'read-only' | 'writable' | 'unconfirmed';
+
+/** The object draft's external binding, or `null` for an object the platform owns. */
+export function readExternalBinding(
+ draft: Record | null | undefined,
+): ExternalObjectBinding | null {
+ const external = draft?.external;
+ if (external == null) return null;
+ return {
+ // `ObjectSchema.datasource` defaults to `'default'`; an `external` binding
+ // on it is refused at authoring, which is the producer's door, not this one.
+ datasource: typeof draft?.datasource === 'string' ? draft.datasource : 'default',
+ writable: typeof external === 'object' && (external as { writable?: unknown }).writable === true,
+ };
+}
+
+/**
+ * The binding's write state. Reads the datasource only when the object itself
+ * opts in to writes; keyed on the datasource NAME, never on the client's
+ * identity (ui/AGENTS.md #10).
+ */
+export function useExternalWriteState(binding: ExternalObjectBinding | null): ExternalWriteState | null {
+ const client = useMetadataClient();
+ const clientRef = React.useRef(client);
+ clientRef.current = client;
+ const datasource = binding?.writable ? binding.datasource : null;
+ const [answer, setAnswer] = React.useState<{ datasource: string; allows: boolean | null } | null>(null);
+
+ React.useEffect(() => {
+ if (!datasource) return;
+ let live = true;
+ clientRef.current.get>('datasource', datasource).then(
+ (item) => {
+ if (!live) return;
+ const external = item?.external as { allowWrites?: unknown } | undefined;
+ setAnswer({ datasource, allows: item ? external?.allowWrites === true : null });
+ },
+ () => {
+ if (live) setAnswer({ datasource, allows: null });
+ },
+ );
+ return () => {
+ live = false;
+ };
+ }, [datasource]);
+
+ if (!binding) return null;
+ if (!binding.writable) return 'read-only';
+ if (!answer || answer.datasource !== binding.datasource || answer.allows === null) return 'unconfirmed';
+ return answer.allows ? 'writable' : 'read-only';
+}
+
+/** "From plan_gantt · Read-only" — the per-field source and write-state mark. */
+export function ExternalFieldBadge({
+ binding,
+ state,
+ locale,
+}: {
+ binding: ExternalObjectBinding;
+ state: ExternalWriteState | null;
+ locale?: string;
+}) {
+ const from = tFormat('designer.external.from', locale, { datasource: binding.datasource });
+ const stateLabel =
+ state === 'read-only'
+ ? t('designer.external.readOnly', locale)
+ : state === 'writable'
+ ? t('designer.external.writable', locale)
+ : t('designer.external.unconfirmed', locale);
+ return (
+
+ {`${from} · ${stateLabel}`}
+
+ );
+}
+
+/** The canvas header note: where the fields come from, and why they cannot be reshaped here. */
+export function ExternalObjectNotice({
+ binding,
+ state,
+ locale,
+}: {
+ binding: ExternalObjectBinding;
+ state: ExternalWriteState | null;
+ locale?: string;
+}) {
+ return (
+
+
+
+
+
{tFormat('designer.external.notice', locale, { datasource: binding.datasource })}
+
+
+ );
+}
diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.externalObject-27279.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.externalObject-27279.test.tsx
new file mode 100644
index 00000000000..af69b83f77f
--- /dev/null
+++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.externalObject-27279.test.tsx
@@ -0,0 +1,147 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ADR-0015 external objects in the Studio Data pillar. An object carrying an
+ * `external` binding offers no Add field — neither the Records grid's "+"
+ * column nor the Form toolbar's button — because a new field would be a new
+ * column on the remote table. The pillar says where the fields come from, and
+ * the field inspector it opens gates rename and retype. A platform-owned
+ * object keeps every door (control).
+ *
+ * The REAL `DataPillar`; the records grid is stubbed to the two authoring
+ * affordances it draws from the grid's authoring context.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+// Module scope as well as in the `vi.mock` factory below: the cold transform is paid
+// during collection, which no test or hook timeout clocks (AGENTS.md, flaky-test discipline).
+import '@object-ui/components';
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+
+const FIELDS = { task_name: { type: 'text', label: 'Task name' } };
+let servedObject: Record = {};
+
+const mockClient = {
+ list: vi.fn(async () => [{ name: String(servedObject.name), label: 'Task' }]),
+ listDrafts: vi.fn(async () => []),
+ layered: vi.fn(async () => ({ effective: servedObject, code: servedObject })),
+ getDraft: vi.fn(async () => null),
+ get: vi.fn(async () => null),
+ save: vi.fn(async () => ({ ok: true })),
+};
+
+vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
+});
+
+vi.mock('./packages-io', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, fetchPackages: vi.fn(async () => []) };
+});
+
+vi.mock('@object-ui/react', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, useAdapter: () => ({}) };
+});
+
+vi.mock('@object-ui/plugin-view', async (importOriginal) => {
+ const mod = await importOriginal>();
+ const { useGridFieldAuthoring } = await import('@object-ui/components');
+ function GridDouble() {
+ const authoring = useGridFieldAuthoring();
+ return (
+
+ {authoring?.onAddColumn && (
+
+ Add column
+
+ )}
+ {authoring?.onEditColumn && (
+ authoring.onEditColumn?.('task_name')}>
+ Edit column
+
+ )}
+
+ );
+ }
+ return { ...mod, ObjectView: GridDouble };
+});
+
+import { DataPillar } from './StudioDesignSurface';
+import { t } from '../metadata-admin/i18n';
+import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
+import { ObjectFieldInspector } from '../metadata-admin/inspectors/ObjectFieldInspector';
+
+// The rail holds the REAL registered field inspector, as the console registers it.
+registerMetadataInspector('object', ObjectFieldInspector);
+
+const FORM = t('engine.studio.data.tab.form', 'en');
+const ADD_FIELD = t('engine.studio.data.addField', 'en');
+
+beforeEach(() => {
+ for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) {
+ if (!(m in Element.prototype)) {
+ (Element.prototype as unknown as Record)[m] = m === 'hasPointerCapture' ? () => false : () => {};
+ }
+ }
+ if (!('scrollIntoView' in Element.prototype)) {
+ (Element.prototype as unknown as Record).scrollIntoView = () => {};
+ }
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+async function renderPillar(object: Record) {
+ servedObject = object;
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 });
+}
+
+async function openFormToolbar() {
+ await userEvent.click(screen.getAllByRole('button', { name: FORM })[0]);
+ return screen.findByTestId('form-toolbar');
+}
+
+describe('Data pillar — external object (ADR-0015)', () => {
+ const EXTERNAL = { name: 'gantt_task', label: 'Task', datasource: 'plan_gantt', external: {}, fields: FIELDS };
+
+ it('offers no Add field in the Records grid or the Form toolbar, and names the datasource', async () => {
+ await renderPillar(EXTERNAL);
+ expect(screen.queryByTestId('grid-add-column')).toBeNull();
+ expect(within(screen.getByTestId('external-object-notice')).getByTestId('external-field-badge')).toHaveTextContent(
+ 'From plan_gantt · Read-only',
+ );
+ const toolbar = await openFormToolbar();
+ expect(within(toolbar).queryByRole('button', { name: ADD_FIELD })).toBeNull();
+ // A writable: false object never asks the datasource.
+ expect(mockClient.get).not.toHaveBeenCalled();
+ });
+
+ it('the column editor still opens the field inspector, which marks the field and gates rename and retype', async () => {
+ await renderPillar(EXTERNAL);
+ await userEvent.click(screen.getByTestId('grid-edit-task_name'));
+ const inspector = await screen.findByTestId('data-field-inspector');
+ expect(within(inspector).getByTestId('external-field-badge')).toHaveTextContent('From plan_gantt · Read-only');
+ expect(within(inspector).getByTestId('field-apiname-input')).toBeDisabled();
+ expect(within(inspector).getByRole('combobox', { name: t('designer.field.type', 'en') })).toBeDisabled();
+ });
+
+ it('CONTROL — a platform-owned object keeps both Add field doors and shows no source note', async () => {
+ await renderPillar({ name: 'plan_task', label: 'Task', fields: FIELDS });
+ expect(screen.getByTestId('grid-add-column')).toBeInTheDocument();
+ expect(screen.queryByTestId('external-object-notice')).toBeNull();
+ const toolbar = await openFormToolbar();
+ expect(within(toolbar).getByRole('button', { name: ADD_FIELD })).toBeInTheDocument();
+ });
+});
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
index f36bcb4e28a..99ad7e72911 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
@@ -342,6 +342,11 @@ import {
toFieldNameLoose,
} from '../metadata-admin/previews/object-fields-io.js';
import type { FieldTypeId } from '../metadata-admin/previews/field-types.js';
+import {
+ ExternalObjectNotice,
+ readExternalBinding,
+ useExternalWriteState,
+} from '../metadata-admin/previews/external-object.js';
import { CreateItemDialog } from './CreateItemDialog.js';
import { AddFieldPopover } from './AddFieldPopover.js';
// objectui#11787 — the shared draft autosave, moved to its own module unchanged.
@@ -6383,6 +6388,13 @@ export function DataPillar({
const requestedSurface = useRequestedSurface();
const appliedRequestRef = React.useRef(0);
const [objDraft, setObjDraft] = React.useState>({});
+ // ADR-0015 — an external object's table belongs to the remote database: the
+ // pillar offers no Add field on it (grid "+" column, Form toolbar) and says
+ // where its fields come from. Rename / retype / duplicate / remove are gated
+ // in the shared field inspector.
+ const objExternal = readExternalBinding(objDraft);
+ const objExternalWriteState = useExternalWriteState(objExternal);
+ const objIsExternal = objExternal !== null;
// objectui#11272 — the object `objDraft` was loaded for, `object:NAME`:
// written where the load below installs it, and nowhere else.
const [objDraftFor, setObjDraftFor] = React.useState('');
@@ -7008,7 +7020,7 @@ export function DataPillar({
// the label derived none (or a taken one); otherwise it is derived here.
const addField = React.useCallback((label: string, type: FieldTypeId, typedName?: string) => {
// objectui#11272 — never onto another object's buffer (not offered then).
- if (readOnly || !objLoaded) return;
+ if (readOnly || !objLoaded || objIsExternal) return;
const view = readFields(objDraft.fields);
const existing = view.entries.map((e) => e.name);
const name = typedName && !existing.includes(typedName) ? typedName : addedFieldName(label, existing);
@@ -7016,7 +7028,8 @@ export function DataPillar({
setObjDraft((d) => ({ ...d, fields: writeFields(view) }));
setDirty(true);
setFieldSel({ kind: 'field', id: name });
- }, [objDraft, readOnly, objLoaded]);
+ }, [objDraft, readOnly, objLoaded, objIsExternal]);
+ const canAddFields = !readOnly && !objIsExternal;
const addFieldTakenNames = React.useMemo(
() => readFields(objDraft.fields).entries.map((e) => e.name),
[objDraft.fields],
@@ -7405,6 +7418,11 @@ export function DataPillar({
className={cn('mb-2 px-3 py-1.5 text-[11px]', bodyFlush && 'mx-6 mt-3')}
/>
)}
+ {objLoaded && objExternal && (
+
+
+
+ )}
{!objLoaded ? (
// objectui#11272 — no view of another object's buffer under
// this one; after a failed load, the error above says why.
@@ -7487,7 +7505,7 @@ export function DataPillar({
}
}}
>
- {!readOnly && (
+ {canAddFields && (
setGridAddFieldOpen(true),
addColumnLabel: t('engine.studio.data.addField', locale),
- onReorderFields: doReorderFields,
- }),
+ }
+ : {}),
onEditColumn: (fieldName) => {
// ignore non-field columns (e.g. the row-actions column)
if (readFields(objDraft.fields).entries.some((e) => e.name === fieldName)) {
@@ -7570,7 +7590,7 @@ export function DataPillar({
onSelectField={(name) => setFieldSel({ kind: 'field', id: name })}
selectedGroup={fieldSel?.kind === 'group' ? fieldSel.id : null}
onSelectGroup={(key) => setFieldSel({ kind: 'group', id: key })}
- onAddField={addField}
+ onAddField={canAddFields ? addField : undefined}
readOnly={readOnly}
modeControl={formModeControl}
/>
diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts
index 43cbf44b66e..627eef52344 100644
--- a/ui/packages/i18n/src/namespaces/studio/en.ts
+++ b/ui/packages/i18n/src/namespaces/studio/en.ts
@@ -293,6 +293,15 @@ const STUDIO_EN: Record = {
'designer.canvas.removeSectionHint': 'Remove section (fields move to Ungrouped)',
'designer.canvas.renameHint': 'Double-click to rename',
'designer.canvas.sectionN': 'Section {n}',
+ // External (ADR-0015) objects: field source and write state
+ 'designer.external.from': 'From {datasource}',
+ 'designer.external.readOnly': 'Read-only',
+ 'designer.external.writable': 'Writable',
+ 'designer.external.unconfirmed': 'Writes unconfirmed',
+ 'designer.external.unconfirmedHint':
+ 'Writes need allowWrites on datasource {datasource} as well as writable on this object, and the datasource could not be read.',
+ 'designer.external.notice':
+ 'External object: its fields mirror a table in datasource {datasource}, so fields cannot be added, removed, renamed or retyped here.',
// Field stubs (disabled runtime-preview placeholders)
'designer.stub.pickDate': 'Pick a date…',
'designer.stub.off': 'Off',
diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts
index 41f8aac5d6a..426448cc84e 100644
--- a/ui/packages/i18n/src/namespaces/studio/zh.ts
+++ b/ui/packages/i18n/src/namespaces/studio/zh.ts
@@ -233,6 +233,13 @@ const STUDIO_ZH: Record = {
'designer.canvas.removeSectionHint': '删除分组(字段移至未分组)',
'designer.canvas.renameHint': '双击重命名',
'designer.canvas.sectionN': '分组 {n}',
+ // External (ADR-0015) objects: field source and write state
+ 'designer.external.from': '来自 {datasource}',
+ 'designer.external.readOnly': '只读',
+ 'designer.external.writable': '可写',
+ 'designer.external.unconfirmed': '写入未确认',
+ 'designer.external.unconfirmedHint': '写入需要数据源 {datasource} 开启 allowWrites,且本对象开启 writable;未能读取该数据源。',
+ 'designer.external.notice': '外部对象:字段映射数据源 {datasource} 中的表,不能在此添加、删除、重命名字段或更改字段类型。',
// Field stubs (disabled runtime-preview placeholders)
'designer.stub.pickDate': '选择日期…',
'designer.stub.off': '关闭',