diff --git a/.changeset/12725-draft-marks.md b/.changeset/12725-draft-marks.md
new file mode 100644
index 0000000000..42c4629149
--- /dev/null
+++ b/.changeset/12725-draft-marks.md
@@ -0,0 +1,12 @@
+---
+'@object-ui/data-objectstack': minor
+'@object-ui/app-shell': minor
+---
+
+Studio marks the settings the AI copilot drafted: a Draft chip and a Source line ("Added as a draft by the AI copilot 2 min ago") on a field or a list-view setting the copilot changed, read from the platform's per-key draft provenance (objectui#12725, item 3; objectstack#24298 and #24395).
+
+- **`@object-ui/data-objectstack` reads and writes the provenance.** The strict draft read (`getDraft`) serves `draftChanges` beside `item`: one `{ path, by, actor, at }` per changed key path. New exports beside `extractDraftBody`: `readDraftChanges(resp)` (the envelope's entries, or `null` when the read recorded none, never `[]`), `governingDraftChange(changes, path)` (the entry that answers for a key: the longest prefix), `draftChangesUnder(changes, path)`, and the `DraftChange` type, derived from `@objectstack/spec`'s `GetMetaItemResponse`. `MetadataClientSaveOptions` gains `surface?: 'studio' | 'api'`, sent once as `?surface=`, derived from the spec's `MetadataAuthoringSurface` minus `copilot` (the HTTP doors refuse it). `MetadataDeleteOptions` no longer inherits it: the delete door reads no such parameter.
+- **Studio's own draft saves declare `surface: 'studio'`,** so a person's edit there is recorded as `studio`, not `api`.
+- **The marks.** The Data pillar's field properties show the chip and the Source line when the field's entry, or an entry under it, is the copilot's. An `object` leaf's list-view properties show the chip on each setting the copilot changed (card fields, title, click to open, group by, columns, filter, sort), mark the elements it added to an array setting by comparing the draft's array with the published one, and end with a Source line naming them. A `studio` or `api` entry draws nothing, and so does a draft whose read recorded no provenance. To compare arrays, the list-view panel reads the published layer beside a draft when, and only when, the draft carries a copilot entry.
+
+The new copy lives in the metadata-admin designer's own string tables (en and zh).
diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts
index ba7fdfc4ef..d7a0f14644 100644
--- a/packages/app-shell/src/views/metadata-admin/i18n.ts
+++ b/packages/app-shell/src/views/metadata-admin/i18n.ts
@@ -3444,6 +3444,17 @@ const ENGINE_STRINGS_EN: Record = {
'engine.studio.inspector.listView.kanbanCard.mode.none': "Don't open",
'engine.studio.inspector.listView.kanbanCard.groupBy': 'Group by field',
'engine.studio.inspector.listView.kanbanCard.pickField': 'Pick a field',
+ // objectui#12725 — the copilot's draft marks on a field or a view setting it changed.
+ 'engine.studio.draftMark.chip': 'Draft',
+ 'engine.studio.draftMark.sourceLabel': 'Source',
+ 'engine.studio.draftMark.source': 'Added as a draft by the AI copilot {when}',
+ 'engine.studio.draftMark.sourceJustNow': 'Added as a draft by the AI copilot just now',
+ 'engine.studio.draftMark.sourceItems': '{items}: {count} added as a draft by the AI copilot {when}',
+ 'engine.studio.draftMark.sourceItemsJustNow': '{items}: {count} added as a draft by the AI copilot just now',
+ 'engine.studio.draftMark.item': '"{label}"',
+ 'engine.studio.draftMark.minutesAgo': '{n} min ago',
+ 'engine.studio.draftMark.hoursAgo': '{n} h ago',
+ 'engine.studio.draftMark.daysAgo': '{n} d ago',
// objectui#11823 (steps 2 and 3) — the Interfaces pillar's New dashboard / New report / New page.
'engine.studio.interfaces.create.menuTitle': 'Create a dashboard, a report or a page in this package',
'engine.studio.interfaces.create.dashboard': 'New dashboard',
@@ -6771,6 +6782,16 @@ const ENGINE_STRINGS_ZH: Record = {
'engine.studio.inspector.listView.kanbanCard.mode.none': '不打开',
'engine.studio.inspector.listView.kanbanCard.groupBy': '分组字段',
'engine.studio.inspector.listView.kanbanCard.pickField': '选择字段',
+ 'engine.studio.draftMark.chip': '草稿',
+ 'engine.studio.draftMark.sourceLabel': '来源',
+ 'engine.studio.draftMark.source': '由 AI 副驾在 {when}作为草稿加入',
+ 'engine.studio.draftMark.sourceJustNow': '由 AI 副驾刚刚作为草稿加入',
+ 'engine.studio.draftMark.sourceItems': '{items} {count} 项由 AI 副驾在 {when}作为草稿加入',
+ 'engine.studio.draftMark.sourceItemsJustNow': '{items} {count} 项由 AI 副驾刚刚作为草稿加入',
+ 'engine.studio.draftMark.item': '「{label}」',
+ 'engine.studio.draftMark.minutesAgo': '{n} 分钟前',
+ 'engine.studio.draftMark.hoursAgo': '{n} 小时前',
+ 'engine.studio.draftMark.daysAgo': '{n} 天前',
// objectui#11823 (step 2) — the Interfaces pillar's New dashboard / New report.
'engine.studio.interfaces.create.menuTitle': '在本包中新建仪表盘、报表或页面',
'engine.studio.interfaces.create.dashboard': '新建仪表盘',
diff --git a/packages/app-shell/src/views/studio-design/DataPillar.copilotDraftMarks-12725.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.copilotDraftMarks-12725.test.tsx
new file mode 100644
index 0000000000..4af2bc5b90
--- /dev/null
+++ b/packages/app-shell/src/views/studio-design/DataPillar.copilotDraftMarks-12725.test.tsx
@@ -0,0 +1,164 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#12725 (item 3) — the Data pillar's field properties mark a field
+ * the AI copilot changed: the draft chip in the rail's header, and the source
+ * line 「由 AI 副驾在 N 分钟前作为草稿加入」 under the field's properties.
+ *
+ * What it reads: the object's strict draft read (`getDraft`) serves the
+ * pending draft's per-key provenance, `draftChanges`, beside `item`
+ * (objectstack#24298 / #24395). The field's key path is `["fields", NAME]`,
+ * and the entry that answers for it is the longest prefix.
+ *
+ * Pinned:
+ * - a field whose answering entry is `by: 'copilot'` shows the chip and the
+ * source line, with N minutes from `at`;
+ * - `studio` and `api` show neither (a person in Studio, any other caller);
+ * - CONTROL: a draft whose read recorded no `draftChanges` shows neither, and
+ * neither does a published object with no draft.
+ *
+ * The rail holds the REAL registered `ObjectFieldInspector`.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+const objectDef = {
+ name: 'showcase_task',
+ label: 'Task',
+ fields: {
+ priority: { name: 'priority', label: 'Priority', type: 'text' },
+ renewal_count: { name: 'renewal_count', label: 'Renewals', type: 'number' },
+ },
+};
+
+const minutesAgo = (n: number) => new Date(Date.now() - n * 60_000 - 5_000).toISOString();
+
+/** What the next draft read serves: `null` for no draft. */
+let draftEnvelope: Record | null = null;
+
+const mockClient = {
+ save: vi.fn(async () => ({})),
+ list: vi.fn(async (type: string) => (type === 'object' ? [{ name: 'showcase_task', label: 'Task' }] : [])),
+ listDrafts: vi.fn(async () => []),
+ layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })),
+ getDraft: vi.fn(async () => draftEnvelope),
+ get: vi.fn(async () => null),
+ withPreviewDrafts() {
+ return this;
+ },
+};
+
+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: () => dataSource };
+});
+
+import { DataPillar } from './StudioDesignSurface';
+import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource';
+import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
+import { ObjectFieldInspector } from '../metadata-admin/inspectors/ObjectFieldInspector';
+import { t, tFormat } from '../metadata-admin/i18n';
+
+const dataSource = createEmptyDataSource();
+failOnAbsorbedFetchError();
+
+registerMetadataInspector('object', ObjectFieldInspector);
+
+beforeEach(() => {
+ draftEnvelope = null;
+});
+afterEach(cleanup);
+
+const L = (key: string) => t(key, 'en-US');
+
+/** A draft read of the object, the draft body as-is, with `draftChanges` when given. */
+function serveDraft(draftChanges?: unknown[]) {
+ draftEnvelope = {
+ type: 'object',
+ name: 'showcase_task',
+ item: { ...objectDef, _draft: true },
+ version: 'v1',
+ ...(draftChanges ? { draftChanges } : {}),
+ };
+}
+
+/** Render the pillar, open Form, select the field labelled `label`, and return the open rail. */
+async function openRail(label: string, name: string): Promise {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(await screen.findByRole('button', { name: L('engine.studio.data.tab.form') }));
+ const card = (await screen.findByText(label)).closest('[role="button"]') as HTMLElement;
+ fireEvent.click(card);
+ const rail = await waitFor(() => {
+ const el = screen.getByTestId('data-field-inspector');
+ expect(el).toHaveTextContent(L('engine.studio.data.fieldProps'));
+ return el;
+ });
+ // The shipped inspector is mounted: its API-name control carries the field's name.
+ expect(await within(rail).findByDisplayValue(name, undefined, { timeout: 8000 })).toBeInTheDocument();
+ return rail;
+}
+
+const SOURCE_2_MIN = tFormat('engine.studio.draftMark.source', 'en-US', {
+ when: tFormat('engine.studio.draftMark.minutesAgo', 'en-US', { n: 2 }),
+});
+
+describe('Data pillar field properties — the copilot’s draft marks (objectui#12725)', () => {
+ it('a field the copilot changed shows the draft chip and the source line, N minutes from `at`', async () => {
+ serveDraft([
+ { path: [], by: 'studio', actor: 'usr_1', at: minutesAgo(30) },
+ { path: ['fields', 'renewal_count'], by: 'copilot', actor: 'usr_1', at: minutesAgo(2) },
+ ]);
+ const rail = await openRail('Renewals', 'renewal_count');
+ expect(within(rail).getByTestId('studio-draft-chip')).toHaveTextContent(L('engine.studio.draftMark.chip'));
+ const source = within(rail).getByTestId('studio-draft-source');
+ expect(source).toHaveTextContent(L('engine.studio.draftMark.sourceLabel'));
+ expect(source).toHaveTextContent(SOURCE_2_MIN);
+ });
+
+ it('a copilot change to one property of the field marks the field too (an entry under its path)', async () => {
+ serveDraft([{ path: ['fields', 'renewal_count', 'label'], by: 'copilot', actor: 'usr_1', at: minutesAgo(2) }]);
+ const rail = await openRail('Renewals', 'renewal_count');
+ expect(within(rail).getByTestId('studio-draft-source')).toHaveTextContent(SOURCE_2_MIN);
+ });
+
+ it.each(['studio', 'api'])('a field last changed through `%s` shows no copilot mark', async (by) => {
+ serveDraft([
+ { path: ['fields', 'renewal_count'], by: 'copilot', actor: 'usr_1', at: minutesAgo(9) },
+ { path: ['fields', 'priority'], by, actor: 'usr_1', at: minutesAgo(2) },
+ ]);
+ const rail = await openRail('Priority', 'priority');
+ expect(within(rail).queryByTestId('studio-draft-chip')).toBeNull();
+ expect(within(rail).queryByTestId('studio-draft-source')).toBeNull();
+ });
+
+ it('CONTROL: a draft whose read recorded no `draftChanges` shows no mark', async () => {
+ serveDraft();
+ const rail = await openRail('Renewals', 'renewal_count');
+ expect(within(rail).queryByTestId('studio-draft-chip')).toBeNull();
+ expect(within(rail).queryByTestId('studio-draft-source')).toBeNull();
+ });
+
+ it('CONTROL: a published object with no pending draft shows no mark', async () => {
+ const rail = await openRail('Renewals', 'renewal_count');
+ expect(within(rail).queryByTestId('studio-draft-chip')).toBeNull();
+ expect(within(rail).queryByTestId('studio-draft-source')).toBeNull();
+ });
+});
diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx
index d1b28ff7b1..437087db91 100644
--- a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx
+++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx
@@ -104,7 +104,7 @@ describe('ObjectHooksPanel — no curated hook editor registered (#6846)', () =>
expect.objectContaining({ handler: 'guard_fn_v2' }),
// objectui#11773 — no draft of this hook was pending at the load: its
// first save is the first-write pin.
- { mode: 'draft', packageId: 'com.example.showcase', ifNoneMatch: '*' },
+ { mode: 'draft', packageId: 'com.example.showcase', ifNoneMatch: '*', surface: 'studio' },
);
});
});
diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx
index 9be9d1f606..2a5eaf413c 100644
--- a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx
+++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx
@@ -290,7 +290,7 @@ export function ObjectHooksPanel({
setSaving(true);
setError(null);
try {
- if ((await saveHookDraft('hook', String(draft.name), draft, { mode: 'draft', packageId })) === 'reloaded') {
+ if ((await saveHookDraft('hook', String(draft.name), draft, { mode: 'draft', packageId, surface: 'studio' })) === 'reloaded') {
// objectui#11773 — the author chose the saved version: the list re-read
// installs it over this buffer, which holds nothing of theirs to keep.
setDirty(false);
@@ -363,7 +363,7 @@ export function ObjectHooksPanel({
try {
// objectui#11773 — a create is the first-write pin: refused where the
// name already holds a draft, instead of written over it.
- await client.save('hook', name, fresh, { mode: 'draft', packageId, ifNoneMatch: '*' });
+ await client.save('hook', name, fresh, { mode: 'draft', packageId, ifNoneMatch: '*', surface: 'studio' });
// objectui#11787 — a new hook is a new draft: the count says so.
onDraftSaved?.();
setNonce((n) => n + 1);
diff --git a/packages/app-shell/src/views/studio-design/ObjectListViewInspector.copilotMarks-12725.test.tsx b/packages/app-shell/src/views/studio-design/ObjectListViewInspector.copilotMarks-12725.test.tsx
new file mode 100644
index 0000000000..9fac75fd81
--- /dev/null
+++ b/packages/app-shell/src/views/studio-design/ObjectListViewInspector.copilotMarks-12725.test.tsx
@@ -0,0 +1,263 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#12725 (item 3) — an `object` leaf's list-view properties mark the
+ * view settings the AI copilot changed, and the elements it added to an array
+ * setting (a kanban card's fields), from the draft read's `draftChanges`
+ * (objectstack#24298 / #24395).
+ *
+ * The REAL hook (`useObjectListViewDraft`) and the REAL panel over a REAL
+ * `MetadataClient`, whose transport is an in-memory server answering as the
+ * framework does.
+ *
+ * Pinned:
+ * - a setting whose answering entry is `by: 'copilot'` shows the draft chip;
+ * the elements the copilot added to `kanban.columns` are marked and the
+ * published ones are not, read by comparing the draft's array with the
+ * published one — the ONE layered read this adds beside a draft;
+ * - the source line names the added elements and the minutes since `at`;
+ * - a `studio` entry draws nothing and adds no read;
+ * - CONTROLS: a published view with no draft draws nothing; a draft whose read
+ * recorded no `draftChanges` draws nothing and sends no `/layers`; a draft
+ * created over no published body (`[]`) marks every element without one;
+ * - the panel's save sends `?surface=studio`, once.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';
+import { MetadataClient } from '@object-ui/data-objectstack';
+
+const PKG = 'com.acme.app';
+const VIEW = 'acme_loan.default';
+
+const OBJECT_DEF = {
+ name: 'acme_loan',
+ label: 'Loan',
+ fields: {
+ book: { name: 'book', label: 'Book', type: 'text' },
+ reader: { name: 'reader', label: 'Reader', type: 'text' },
+ due_date: { name: 'due_date', label: 'Due date', type: 'date' },
+ status: { name: 'status', label: 'Status', type: 'text' },
+ },
+};
+
+/** The object's default list view as a kanban board, with these card fields. */
+const kanbanView = (cardFields: string[], extra: Record = {}) => ({
+ name: VIEW,
+ object: 'acme_loan',
+ viewKind: 'list',
+ isDefault: true,
+ config: {
+ type: 'kanban',
+ data: { provider: 'object', object: 'acme_loan' },
+ columns: ['book', 'status'],
+ kanban: { groupByField: 'status', columns: cardFields },
+ ...extra,
+ },
+});
+
+const server = {
+ active: new Map>(),
+ drafts: new Map; draftChanges?: unknown[] }>(),
+ requests: [] as Array<{ method: string; path: string; search: string }>,
+};
+
+const json = (status: number, body: unknown) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+async function serve(input: RequestInfo | URL, init?: RequestInit): Promise {
+ const method = (init?.method ?? 'GET').toUpperCase();
+ const url = new URL(String(input), 'http://studio.test');
+ server.requests.push({ method, path: url.pathname, search: url.search });
+ const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
+ if (!meta) return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
+ const seg = meta[1].split('/').map(decodeURIComponent);
+ const key = `${seg[0]}/${seg[1]}`;
+ if (seg[0] === '_drafts') {
+ return json(200, { drafts: [...server.drafts.keys()].map((k) => ({ type: k.split('/')[0], name: k.split('/')[1], packageId: PKG })) });
+ }
+ if (method === 'PUT') return json(200, { success: true, version: 'v2', state: 'draft' });
+ if (seg[0] === 'object' && seg.length === 2) return json(200, { type: 'object', name: seg[1], item: OBJECT_DEF });
+ if (seg.length === 3 && seg[2] === 'layers') {
+ const row = server.active.get(key);
+ return row
+ ? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row })
+ : json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
+ }
+ if (seg.length === 2 && url.searchParams.get('state') === 'draft') {
+ const draft = server.drafts.get(key);
+ if (!draft) return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } });
+ return json(200, {
+ type: seg[0],
+ name: seg[1],
+ item: { ...draft.row, _draft: true },
+ version: 'v1',
+ ...(draft.draftChanges ? { draftChanges: draft.draftChanges } : {}),
+ });
+ }
+ const row = server.active.get(key);
+ return row ? json(200, { type: seg[0], name: seg[1], item: row }) : json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
+}
+
+const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
+
+vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, useMetadataClient: () => client };
+});
+
+import { ObjectListViewInspector, useObjectListViewDraft } from './ObjectListViewInspector';
+import type { Surface } from './navSurface';
+import { tFormat } from '../metadata-admin/i18n';
+
+const METADATA: MetadataContextValue = {
+ apps: [],
+ objects: [OBJECT_DEF],
+ dashboards: [],
+ reports: [],
+ pages: [],
+ loading: false,
+ error: null,
+ refresh: async () => {},
+ invalidate: () => {},
+ ensureType: async () => [],
+ getItem: async () => null,
+ getItemsByType: () => [],
+};
+
+const LEAF: Surface = { type: 'object', name: 'acme_loan', label: 'Loans' };
+const minutesAgo = (n: number) => new Date(Date.now() - n * 60_000 - 5_000).toISOString();
+
+function Host() {
+ const listView = useObjectListViewDraft({ client, packageId: PKG, leaf: LEAF, publishNonce: 0 });
+ return (
+ <>
+
+
)}
-
+
{t('engine.studio.inspector.listView.kanbanCard.fields', locale)}
+ {marks.fields && }
+ {marks.addedFields.length > 0 && (
+
+ )}
-
-
+
{t('engine.studio.inspector.listView.kanbanCard.open', locale)}
+ {marks.open && }
-
+
{t('engine.studio.inspector.listView.kanbanCard.groupBy', locale)}
+ {marks.groupBy && }