From bfb2d3b6ff68a6094de08514921706a41787dd11 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:42:30 +0000 Subject: [PATCH 1/4] wip(metadata): record the MCP surface and OAuth client on draft provenance Claude-Session: https://claude.ai/code/session_01QUs3BthV9Hg8gmo8r35XcV Co-Authored-By: Claude --- .../src/draft-changes.test.ts | 40 ++++++++- .../metadata-protocol/src/draft-changes.ts | 50 ++++++++++- .../protocol.packageless-save-address.test.ts | 53 +++++++++++- packages/metadata-protocol/src/protocol.ts | 35 ++++++-- .../src/sys-metadata-repository.ts | 20 ++++- packages/rest/src/meta-save-request.ts | 17 +++- .../rest-server-meta-door-closed-sets.test.ts | 1 + .../meta-save-preconditions-parity.test.ts | 4 + packages/spec/src/api/protocol.zod.ts | 83 +++++++++++++++---- .../src/type-alias-convention.pin.test.ts | 12 ++- 10 files changed, 279 insertions(+), 36 deletions(-) diff --git a/packages/metadata-protocol/src/draft-changes.test.ts b/packages/metadata-protocol/src/draft-changes.test.ts index 55c385cf99b..8d5217032e8 100644 --- a/packages/metadata-protocol/src/draft-changes.test.ts +++ b/packages/metadata-protocol/src/draft-changes.test.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from 'vitest'; import { GetMetaItemResponseSchema } from '@objectstack/spec/api'; -import { draftChangedPaths, mergeDraftChanges, storedDraftChanges, type DraftChangeEntry } from './draft-changes.js'; +import { authoringClientOf, draftChangedPaths, mergeDraftChanges, storedDraftChanges, type DraftChangeEntry } from './draft-changes.js'; const AT = '2026-10-18T12:00:00.000Z'; const AT2 = '2026-10-18T12:05:00.000Z'; @@ -105,6 +105,36 @@ describe('mergeDraftChanges — no two entries share a path; the longest prefix const entries = mergeDraftChanges([], [['label']], copilot); expect(mergeDraftChanges(entries, [['label']], studio)).toEqual([{ path: ['label'], ...studio }]); }); + + it('[#27176] an `mcp` stamp carries its client onto each path; a stamp with none adds no `client` key', () => { + const mcp = { by: 'mcp' as const, actor: 'u_1', at: AT, client: { id: 'cli_1', name: 'Claude' } }; + expect(mergeDraftChanges([], [['label'], ['columns']], mcp)).toEqual([ + { path: ['label'], ...mcp }, + { path: ['columns'], ...mcp }, + ]); + const [bare] = mergeDraftChanges([], [['label']], { by: 'mcp', actor: null, at: AT }); + expect('client' in bare!).toBe(false); + }); +}); + +describe('[#27176] authoringClientOf — the one shape a client takes', () => { + it('copies a non-empty id and an optional non-empty name, member by member', () => { + expect(authoringClientOf({ id: 'cli_1' })).toEqual({ id: 'cli_1' }); + expect(authoringClientOf({ id: 'cli_1', name: 'Claude' })).toEqual({ id: 'cli_1', name: 'Claude' }); + }); + + it.each([ + ['not an object', 'cli_1'], + ['null', null], + ['an array', [{ id: 'cli_1' }]], + ['an empty id', { id: '' }], + ['no id', { name: 'Claude' }], + ['an empty name', { id: 'cli_1', name: '' }], + ['a non-string name', { id: 'cli_1', name: 7 }], + ['an extra member', { id: 'cli_1', secret: 's' }], + ])('%s is not a client', (_label, value) => { + expect(authoringClientOf(value)).toBeUndefined(); + }); }); describe('storedDraftChanges — the stored form, never []', () => { @@ -115,6 +145,12 @@ describe('storedDraftChanges — the stored form, never []', () => { expect(storedDraftChanges([entry])).toEqual([entry]); }); + it('[#27176] reads an `mcp` entry with its client, and one without', () => { + const mcp: DraftChangeEntry = { path: ['label'], by: 'mcp', actor: 'u_1', at: AT, client: { id: 'cli_1', name: 'Claude' } }; + const bare: DraftChangeEntry = { path: ['columns'], by: 'mcp', actor: 'u_1', at: AT }; + expect(storedDraftChanges(JSON.stringify([mcp, bare]))).toEqual([mcp, bare]); + }); + it.each([ ['NULL (a row saved before the writer)', null], ['an absent column', undefined], @@ -124,6 +160,8 @@ describe('storedDraftChanges — the stored form, never []', () => { ['an unknown surface', JSON.stringify([{ ...entry, by: 'robot' }])], ['an empty segment', JSON.stringify([{ ...entry, path: [''] }])], ['a dotted-string path', JSON.stringify([{ ...entry, path: 'a.b' }])], + ['a client beside a surface other than mcp', JSON.stringify([{ ...entry, client: { id: 'cli_1' } }])], + ['a malformed client', JSON.stringify([{ ...entry, by: 'mcp', client: { id: '' } }])], ])('%s reads as not recorded (undefined), never []', (_label, raw) => { expect(storedDraftChanges(raw)).toBeUndefined(); }); diff --git a/packages/metadata-protocol/src/draft-changes.ts b/packages/metadata-protocol/src/draft-changes.ts index 119e99b9e12..92ed3b6ac51 100644 --- a/packages/metadata-protocol/src/draft-changes.ts +++ b/packages/metadata-protocol/src/draft-changes.ts @@ -20,12 +20,16 @@ * (`sys_metadata.draft_changes`), `undefined` when it stores none, so the * read never answers `[]` for a row whose saves recorded nothing. * + * [#27176] An `mcp` write also stamps the OAuth `client` it was made by, on + * the entry beside `by`; {@link authoringClientOf} is the one shape check the + * save door and the stored read both apply to it. + * * The entries live on the draft row, ⛔ never inside the stored body: publish * and discard both delete that row, so the entries go with it. */ import { canonicalize, orderedMapKeys } from '@objectstack/metadata-core'; -import type { MetadataAuthoringSurface } from '@objectstack/spec/api'; +import type { MetadataAuthoringClient, MetadataAuthoringSurface } from '@objectstack/spec/api'; /** One entry of `draftChanges`, as stored and as served. */ export interface DraftChangeEntry { @@ -33,6 +37,8 @@ export interface DraftChangeEntry { readonly by: MetadataAuthoringSurface; readonly actor: string | null; readonly at: string; + /** Only beside `by: 'mcp'`, and only when the save stated it. */ + readonly client?: MetadataAuthoringClient; } /** The write a {@link mergeDraftChanges} stamps on each path it changed. */ @@ -40,14 +46,31 @@ export interface DraftChangeStamp { readonly by: MetadataAuthoringSurface; readonly actor: string | null; readonly at: string; + readonly client?: MetadataAuthoringClient; } -const SURFACES: ReadonlySet = new Set(['copilot', 'studio', 'api']); +const SURFACES: ReadonlySet = new Set(['copilot', 'mcp', 'studio', 'api']); function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * `value` as a `MetadataAuthoringClient` (a non-empty `id`, an optional + * non-empty `name`, nothing else), copied member by member, or `undefined` + * when it is not one. The save door refuses a `client` this answers + * `undefined` for; the stored read treats such an entry as malformed. + */ +export function authoringClientOf(value: unknown): MetadataAuthoringClient | undefined { + if (!isPlainObject(value)) return undefined; + const { id, name, ...rest } = value; + if (Object.keys(rest).length > 0) return undefined; + if (typeof id !== 'string' || id === '') return undefined; + if (name === undefined) return { id }; + if (typeof name !== 'string' || name === '') return undefined; + return { id, name }; +} + /** * The key paths a draft save changed. `before` is the body the save replaced * (the draft row's, else the published row's), `undefined` when there was @@ -111,7 +134,16 @@ export function mergeDraftChanges( stamp: DraftChangeStamp, ): DraftChangeEntry[] { const kept = entries.filter((entry) => !paths.some((p) => isAtOrUnder(entry.path, p))); - return [...kept, ...paths.map((path) => ({ path: [...path], by: stamp.by, actor: stamp.actor, at: stamp.at }))]; + return [ + ...kept, + ...paths.map((path) => ({ + path: [...path], + by: stamp.by, + actor: stamp.actor, + at: stamp.at, + ...(stamp.client ? { client: stamp.client } : {}), + })), + ]; } /** @@ -135,7 +167,17 @@ export function storedDraftChanges(raw: unknown): DraftChangeEntry[] | undefined if (typeof by !== 'string' || !SURFACES.has(by)) return undefined; if (actor !== null && typeof actor !== 'string') return undefined; if (typeof at !== 'string') return undefined; - entries.push({ path: [...path] as string[], by: by as MetadataAuthoringSurface, actor, at }); + // [#27176] A client is the MCP door's alone, so one beside any other + // surface is as malformed as a client of the wrong shape. + const client = e.client === undefined ? undefined : authoringClientOf(e.client); + if (e.client !== undefined && (client === undefined || by !== 'mcp')) return undefined; + entries.push({ + path: [...path] as string[], + by: by as MetadataAuthoringSurface, + actor, + at, + ...(client ? { client } : {}), + }); } return entries; } diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts index 9b47d8eeab0..52cd0e78f8e 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -1393,7 +1393,7 @@ describe('[#24395] the draft save records per-key provenance, the strict draft r it('each surface is stamped as `by`; an absent surface stamps `api`; the actor is the save\'s', async () => { const { protocol } = makeProtocol(); - for (const surface of ['copilot', 'studio', 'api', undefined] as const) { + for (const surface of ['copilot', 'mcp', 'studio', 'api', undefined] as const) { const name = `v_${surface ?? 'none'}`; await protocol.saveMetaItem({ type: 'view', name, item: view(name), mode: 'draft', actor: 'u_1', ...(surface ? { surface } : {}), @@ -1495,4 +1495,55 @@ describe('[#24395] the draft save records per-key provenance, the strict draft r expect(envelope).toMatchObject({ status: 400, code: 'INVALID_REQUEST' }); expect(metadataWrites()).toEqual([]); }); + + it('[#27176] an `mcp` save stamps its OAuth client beside `by` on every path it changes; a later save keeps it on the paths it leaves', async () => { + const { protocol } = makeProtocol(); + const client = { id: 'cli_claude', name: 'Claude' }; + await protocol.saveMetaItem({ type: 'view', name: 'v_mcp', item: view('v_mcp', { description: 'pub' }) }); + await protocol.saveMetaItem({ + type: 'view', name: 'v_mcp', mode: 'draft', surface: 'mcp', client, actor: 'u_1', + item: view('v_mcp', { description: 'pub', label: 'Agent', columns: TWO_COLUMNS }), + }); + const first = (await readDraft(protocol, 'v_mcp')); + expect(first.draftChanges).toEqual([ + { path: ['label'], by: 'mcp', actor: 'u_1', at: expect.stringMatching(ISO_Z), client }, + { path: ['columns'], by: 'mcp', actor: 'u_1', at: expect.stringMatching(ISO_Z), client }, + ]); + expect(GetMetaItemResponseSchema.safeParse(first).success).toBe(true); + await protocol.saveMetaItem({ + type: 'view', name: 'v_mcp', mode: 'draft', surface: 'studio', actor: 'u_1', + item: view('v_mcp', { description: 'pub', label: 'Hand', columns: TWO_COLUMNS }), + }); + const second = (await readDraft(protocol, 'v_mcp')).draftChanges; + expect(second[0]).toEqual(first.draftChanges[1]); + expect(second[1]).toEqual({ path: ['label'], by: 'studio', actor: 'u_1', at: expect.stringMatching(ISO_Z) }); + // A client with no registered name is recorded by its id alone. + await protocol.saveMetaItem({ + type: 'view', name: 'v_mcp_id', item: view('v_mcp_id'), mode: 'draft', surface: 'mcp', client: { id: 'cli_x' }, + }); + expect((await readDraft(protocol, 'v_mcp_id')).draftChanges[0].client).toEqual({ id: 'cli_x' }); + // An `mcp` save that states no client records none — absent, never a placeholder. + await protocol.saveMetaItem({ type: 'view', name: 'v_mcp_none', item: view('v_mcp_none'), mode: 'draft', surface: 'mcp' }); + expect('client' in (await readDraft(protocol, 'v_mcp_none')).draftChanges[0]).toBe(false); + }); + + it('[#27176] a `client` beside any surface but `mcp`, or of the wrong shape, is refused 400 INVALID_REQUEST before anything is written', async () => { + const { protocol, metadataWrites } = makeProtocol(); + const cases: Array> = [ + { surface: 'copilot', client: { id: 'cli_1' } }, + { surface: 'studio', client: { id: 'cli_1' } }, + { client: { id: 'cli_1' } }, + { surface: 'mcp', client: { id: '' } }, + { surface: 'mcp', client: { id: 'cli_1', name: '' } }, + { surface: 'mcp', client: { id: 'cli_1', secret: 's' } }, + { surface: 'mcp', client: 'cli_1' }, + ]; + for (const extra of cases) { + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'view', name: 'v_cli', item: view('v_cli'), mode: 'draft', ...extra, + } as any))); + expect(envelope, JSON.stringify(extra)).toMatchObject({ status: 400, code: 'INVALID_REQUEST' }); + } + expect(metadataWrites()).toEqual([]); + }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 7270ca0864c..3349b4afde5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -137,8 +137,8 @@ import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribable import { isAppPackageIdKey } from '@objectstack/spec/api'; // [#24395] The save's authoring surface, and the draft row's per-key // provenance read back for the strict draft read. -import { MetadataAuthoringSurfaceSchema, type MetadataAuthoringSurface } from '@objectstack/spec/api'; -import { storedDraftChanges } from './draft-changes.js'; +import { MetadataAuthoringSurfaceSchema, type MetadataAuthoringClient, type MetadataAuthoringSurface } from '@objectstack/spec/api'; +import { authoringClientOf, storedDraftChanges } from './draft-changes.js'; import { parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, resolveSearchFieldResolution, @@ -20177,7 +20177,7 @@ export class ObjectStackProtocolImplementation implements // STORED content hash itself (`migrateStoredMetadata`): it reaches the // repository as given. ⛔ No transport sets it — every door builds its // request field by field from named inputs, never by spreading a body. - async saveMetaItem(request: { type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace, surface?: MetadataAuthoringSurface }) { + async saveMetaItem(request: { type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace, surface?: MetadataAuthoringSurface, client?: MetadataAuthoringClient }) { // [ADR-0131 D6] FIRST, before every other gate and every read: an // organization-scoped write is refused for every type, draft or // publish. See {@link organizationScopedWriteRefusal}. @@ -20231,11 +20231,28 @@ export class ObjectStackProtocolImplementation implements // before any read or write, with the request-envelope refusal above — // never stamped as `api`, which would record a caller's mistake as // a fact about who wrote the draft. The HTTP doors never send - // `copilot` (`metaSaveRequestOptions`); an in-process tool may. + // `copilot` or `mcp` (`metaSaveRequestOptions`); an in-process tool may. if (request.surface !== undefined && !MetadataAuthoringSurfaceSchema.safeParse(request.surface).success) { const err: any = new Error( - `saveMetaItem 'surface' must be one of copilot, studio or api for '${request.type}/${request.name}'; ` - + `got ${JSON.stringify(request.surface)}. Omit it for a save that is neither the copilot nor Studio (recorded as api).`, + `saveMetaItem 'surface' must be one of copilot, mcp, studio or api for '${request.type}/${request.name}'; ` + + `got ${JSON.stringify(request.surface)}. Omit it for a save that is none of the copilot, an MCP client or Studio (recorded as api).`, + ); + err.code = 'INVALID_REQUEST'; + err.status = 400; + throw err; + } + // [#27176] The OAuth client an `mcp` save names, stated only by the + // MCP build door: refused beside any other surface (a client is the + // MCP door's attribution, never a label another caller borrows) and + // refused malformed, both before anything is read or written. + if (request.client !== undefined + && (request.surface !== 'mcp' || authoringClientOf(request.client) === undefined)) { + const err: any = new Error( + request.surface !== 'mcp' + ? `saveMetaItem 'client' is stated only with surface 'mcp' for '${request.type}/${request.name}'; ` + + `got surface ${JSON.stringify(request.surface ?? null)}. Only the MCP build door names the OAuth client it verified.` + : `saveMetaItem 'client' must be { id, name? } with a non-empty id and, when present, a non-empty name ` + + `for '${request.type}/${request.name}'.`, ); err.code = 'INVALID_REQUEST'; err.status = 400; @@ -21624,7 +21641,11 @@ export class ObjectStackProtocolImplementation implements // server-stated rewrite — `migrate-stored`, the only caller // that states a `source` — is not authoring and records none. ...(mode === 'draft' && request.source === undefined - ? { draftChangesBy: request.surface ?? 'api' } + ? { + draftChangesBy: request.surface ?? 'api', + // [#27176] The client the MCP door verified, beside `mcp`. + ...(request.client !== undefined ? { draftChangesClient: authoringClientOf(request.client) } : {}), + } : {}), }); // Persistence succeeded — NOW it's safe to mutate the diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 71e00f3212e..7a0b1068eff 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -93,10 +93,10 @@ import { isOriginGatedType, managedItemSealedSentence, packagedBaseRegimePrescri // its value from a selector or a body — owned by the migration that keys the // overlay index on it, so the key and the write head cannot disagree. import { OVERLAY_MEMBER_COLUMN, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; -import type { MetadataAuthoringSurface } from '@objectstack/spec/api'; +import type { MetadataAuthoringClient, MetadataAuthoringSurface } from '@objectstack/spec/api'; // [#24395] The draft save's per-key provenance — the diff, the keep rule and // the stored form, one module the strict draft read reads it back through. -import { draftChangedPaths, mergeDraftChanges, storedDraftChanges } from './draft-changes.js'; +import { draftChangedPaths, mergeDraftChanges, storedDraftChanges, type DraftChangeStamp } from './draft-changes.js'; /** * Canonicalise a driver-materialised timestamp into the ISO-8601 string the @@ -980,7 +980,7 @@ export class SysMetadataRepository implements MetadataRepository { ctx: Record; member: string | null | undefined; targetPackageId: string | null; - stamp: { by: MetadataAuthoringSurface; actor: string | null; at: string }; + stamp: DraftChangeStamp; }, ): Promise { let replaced: any | null = existing; @@ -1065,6 +1065,13 @@ export class SysMetadataRepository implements MetadataRepository { * `migrate-stored` rewrite) states. Ignored on an `active` put. */ draftChangesBy?: MetadataAuthoringSurface; + /** + * [#27176] The OAuth client a `draft` put by `draftChangesBy: 'mcp'` + * records beside `by` on every key path it changes. The caller has + * already checked it is stated only with `mcp`; ignored wherever + * `draftChangesBy` is. + */ + draftChangesClient?: MetadataAuthoringClient; }, ): Promise { this.assertOpen(); @@ -1178,7 +1185,12 @@ export class SysMetadataRepository implements MetadataRepository { if (state === 'draft' && opts.draftChangesBy !== undefined) { const draftChanges = await this.recordDraftChanges(ref, body, existing, { ctx, member, targetPackageId: parentRowData.package_id as string | null, - stamp: { by: opts.draftChangesBy, actor: opts.actor ?? null, at: now }, + stamp: { + by: opts.draftChangesBy, + actor: opts.actor ?? null, + at: now, + ...(opts.draftChangesClient ? { client: opts.draftChangesClient } : {}), + }, }); if (draftChanges !== undefined) parentRowData.draft_changes = draftChanges; } diff --git a/packages/rest/src/meta-save-request.ts b/packages/rest/src/meta-save-request.ts index bd9129f2a2a..c0790465956 100644 --- a/packages/rest/src/meta-save-request.ts +++ b/packages/rest/src/meta-save-request.ts @@ -64,7 +64,9 @@ * Its value set is CLOSED and narrower than the schema's: `studio` or `api`, * exactly. `copilot` is refused, because it is the mark of an in-process AI * tool's write and a remote caller cannot be one — an HTTP request saying - * `copilot` would mark its own write as the AI's in Studio. Every other value + * `copilot` would mark its own write as the AI's in Studio. `mcp` is refused + * for the same reason (#27176): only the MCP build door states it, in-process, + * after verifying the client's token. Every other value * is refused too (case included), never read as `api`: a misspelt `Studio` * dropped to `api` would record the person's edit as some other caller's. * Both refusals are `400`, as the precondition's are; neither door mints a code. @@ -89,7 +91,7 @@ export interface MetaSaveRequestHttp { /** The `saveMetaItem` members {@link metaSaveRequestOptions} answers, each present only when the request asked for it. */ export type MetaSaveRequestMembers = Pick; -/** [#24395] The `?surface=` values an HTTP save may state — the schema's set less `copilot`. */ +/** [#24395] The `?surface=` values an HTTP save may state — the schema's set less `copilot` and `mcp` (#27176). */ const HTTP_SAVE_SURFACES: ReadonlySet = new Set(['studio', 'api']); /** {@link metaSaveRequestOptions}'s answer: the members to spread into the save, or the sentence of a `400`. */ @@ -149,6 +151,17 @@ export function metaSaveRequestOptions(http: MetaSaveRequestHttp): MetaSaveReque + 'person editing in Studio, ?surface=api for any other caller, or omit it (recorded as api).', }; } + // [#27176] `mcp` is the MCP build door's, stated in-process once it + // has verified the client's token — never by a request about itself. + if (surface === 'mcp') { + return { + ok: false, + message: '?surface=mcp is not accepted over HTTP: it marks a write as an MCP agent\'s, which only ' + + 'the MCP build door states in-process, after verifying the client\'s token. Send ' + + '?surface=studio for a person editing in Studio, ?surface=api for any other caller, or omit ' + + 'it (recorded as api).', + }; + } if (typeof surface !== 'string' || !HTTP_SAVE_SURFACES.has(surface)) { return { ok: false, diff --git a/packages/rest/src/rest-server-meta-door-closed-sets.test.ts b/packages/rest/src/rest-server-meta-door-closed-sets.test.ts index 42eaf87e820..2ac32e19d1a 100644 --- a/packages/rest/src/rest-server-meta-door-closed-sets.test.ts +++ b/packages/rest/src/rest-server-meta-door-closed-sets.test.ts @@ -255,6 +255,7 @@ describe('[#24395] ?surface= on the REST save door: studio and api reach the sav for (const [label, surface] of [ ['copilot — an in-process tool\'s mark, never a remote caller\'s', 'copilot'], + ['mcp — the MCP build door\'s mark, never a remote caller\'s (#27176)', 'mcp'], ['a case variant, never folded to studio', 'Studio'], ['an unknown value, never read as api', 'robot'], ['an empty value', ''], diff --git a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts index 560fffd66c7..9f424b40800 100644 --- a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts +++ b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts @@ -413,6 +413,10 @@ for (const [doorName, boot] of DOORS) { const refused = await door.save('s_cop', 'never', { query: { mode: 'draft', surface: 'copilot' } }); expect({ status: refused.status, code: refused.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); expect(refused.message).toContain('copilot'); + // [#27176] `mcp` likewise: only the MCP build door states it, in-process. + const mcp = await door.save('s_cop', 'never', { query: { mode: 'draft', surface: 'mcp' } }); + expect({ status: mcp.status, code: mcp.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + expect(mcp.message).toContain('mcp'); const misspelt = await door.save('s_cop', 'never', { query: { mode: 'draft', surface: 'Studio' } }); expect({ status: misspelt.status, code: misspelt.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); expect(await door.stored('s_cop', 'draft')).toEqual([]); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index c3ad70f35a9..aab56f70055 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -486,15 +486,46 @@ const MetadataProtectionEnvelopeFields = { * {@link GetMetaItemResponseSchema}'s `draftChanges[].by` (what the draft read * reports a key was last changed through) are spelled in, so the value a save * states and the value a read serves cannot drift apart. + * + * [#27176] `mcp` joins it: an agent outside the console (an MCP client a person + * consented to with `metadata:write`) building through a deployment's MCP + * build door. Like `copilot` it is an AI tool writing on a person's behalf, and + * like `copilot` only an in-process caller states it: the MCP door runs the + * same authoring tools in the server process, after verifying the client's + * token, so no HTTP save can claim it. */ -export const MetadataAuthoringSurfaceSchema = z.enum(['copilot', 'studio', 'api']).describe( +export const MetadataAuthoringSurfaceSchema = z.enum(['copilot', 'mcp', 'studio', 'api']).describe( 'The authoring surface of a metadata write. Closed: `copilot` is an AI assistant\'s tool writing on a ' - + 'person\'s behalf (Studio\'s AI copilot); `studio` is a person editing in Studio; `api` is any other ' - + 'caller of the save door. It names the surface, not the person: a copilot write and a Studio write of ' - + 'the same user carry the same `actor`.', + + 'person\'s behalf (Studio\'s AI copilot); `mcp` is an AI agent outside the console writing on a ' + + 'person\'s behalf through the MCP build door, as the OAuth client the person consented to; `studio` ' + + 'is a person editing in Studio; `api` is any other caller of the save door. It names the surface, ' + + 'not the person: a copilot write, an MCP write and a Studio write of the same user carry the same ' + + '`actor`.', ); export type MetadataAuthoringSurface = z.input; +/** + * [#27176] The OAuth client an `mcp` write was made by: the client the person + * consented to, as the MCP build door verified it from the request's token. + * `actor` already answers WHO (the consenting person); this answers WHICH + * AGENT, so a draft two clients of one person contributed to tells them apart. + * Stated server-side only, by the door that verified the token: the save's + * `client` is never read off a wire request, so no caller can attribute its + * write to another client. + */ +export const MetadataAuthoringClientSchema = z.object({ + id: z.string().min(1).describe( + 'The OAuth client id the verified token was issued to. Opaque — compare it, never parse it.', + ), + name: z.string().min(1).optional().describe( + 'The client\'s registered display name (its OAuth `client_name`) when it registered one, as ' + + 'registered. Absent when it registered none; ⛔ never the id standing in for it.', + ), +}).describe( + 'The OAuth client an MCP build-door write was made by (see `MetadataAuthoringClientSchema`).', +); +export type MetadataAuthoringClient = z.input; + /** * Get Metadata Item Response * @@ -565,13 +596,15 @@ export type MetadataAuthoringSurface = z.input z.object({ by: MetadataAuthoringSurfaceSchema.describe( 'The authoring surface of the write that last changed this path: the `surface` that save declared ' + '(`SaveMetaItemRequestSchema.surface`), `api` when it declared none. Closed: `copilot` is an AI ' - + 'assistant\'s tool writing on a person\'s behalf (Studio\'s AI copilot); `studio` is a person ' - + 'editing in Studio; `api` is any other caller of the save door. It names the surface, not the ' - + 'person: a copilot write and a Studio write of the same user carry the same `actor`.', + + 'assistant\'s tool writing on a person\'s behalf (Studio\'s AI copilot); `mcp` is an AI agent ' + + 'outside the console writing on a person\'s behalf through the MCP build door; `studio` is a ' + + 'person editing in Studio; `api` is any other caller of the save door. It names the surface, not ' + + 'the person: a copilot write, an MCP write and a Studio write of the same user carry the same ' + + '`actor`.', ), actor: z.string().nullable().describe( 'The identity that write was recorded under: the save\'s `actor`, the value its history event ' + 'carries. `null` when the write recorded none, never a sentinel string.', ), + client: MetadataAuthoringClientSchema.optional().describe( + 'The OAuth client that write was made by: the save\'s `client`, which only the MCP build door ' + + 'states, so it is present only beside `by: \'mcp\'`. ABSENT on every other surface, and on an ' + + '`mcp` entry whose save stated none: absence means "not recorded", never "no client".', + ), at: z.string().datetime().describe( 'When that write happened: an ISO-8601 instant in UTC, written with `Z` (the form ' + '`toISOString()` produces). An offset or a local time is refused.', @@ -902,7 +942,11 @@ export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({ * `draftChanges[].by` reports (`MetadataAuthoringSurfaceSchema`). Unlike * `writeFace` it IS caller-stated, and that is why the HTTP doors read it * with a narrower set than this schema's: a remote caller may say `studio` - * or `api`, and only an in-process tool may say `copilot`. + * or `api`, and only an in-process tool may say `copilot` or `mcp`. + * + * [#27176] `client` is the OAuth client an `mcp` save is made by, and it is + * server-stated like `writeFace`: only the MCP build door sets it, from the + * token it verified, and no HTTP door reads it. * * `writeFace` IS declared, and the distinction with `source` is the point: * both are server-stated, but `writeFace` is sent by three doors (the two @@ -1002,10 +1046,20 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ + 'save (`mode: \'draft\'`) stamps it as `by` on every key path it changes, the entries the strict ' + 'draft read serves as `draftChanges`; an active save records no per-key provenance. ABSENT stamps ' + '`api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which ' - + 'takes `studio` or `api` and refuses every other value `400 VALIDATION_ERROR`, `copilot` ' - + 'included: `copilot` is stated only by an in-process AI tool calling the save, never by a remote ' - + 'caller, so an HTTP request cannot mark its own write as the copilot\'s. A value outside the set ' - + 'in-process is refused `400 INVALID_REQUEST`, before anything is written.', + + 'takes `studio` or `api` and refuses every other value `400 VALIDATION_ERROR`, `copilot` and ' + + '`mcp` included: `copilot` is stated only by an in-process AI tool calling the save and `mcp` ' + + 'only by the MCP build door after it verified the client\'s token, never by a remote caller, so ' + + 'an HTTP request cannot mark its own write as the copilot\'s or an MCP agent\'s. A value outside ' + + 'the set in-process is refused `400 INVALID_REQUEST`, before anything is written.', + ), + client: MetadataAuthoringClientSchema.optional().describe( + 'The OAuth client this save is made by (see `MetadataAuthoringClientSchema`), stated only by the ' + + 'MCP build door, in-process, from the token it verified. Only with `surface: \'mcp\'`: a `client` ' + + 'beside any other surface, or a malformed one, is refused `400 INVALID_REQUEST` before anything ' + + 'is written. A draft save stamps it as `client` beside `by: \'mcp\'` on every key path it ' + + 'changes; an active save records it nowhere. No HTTP door reads it: the REST door and the ' + + 'dispatcher build this request member by member, so a `client` in a wire body or query is never ' + + 'read.', ), })); @@ -2101,7 +2155,8 @@ export const GetPublishedMetaItemResponseSchema = lazySchema(() => z.unknown().d * and when) is deliberately NOT on this row: it is * {@link GetMetaItemResponseSchema}'s `draftChanges`, on the strict draft read * Studio's properties load the item from. `updatedBy` here stays the row's - * last actor, the same person for a copilot write and a hand edit. + * last actor, the same person for a copilot write, an MCP write (#27176) and + * a hand edit. */ export const ListDraftsResponseSchema = lazySchema(() => z.object({ drafts: z.array(z.object({ diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index b91a79ad65d..644a4a08036 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -285,7 +285,7 @@ import type * as M194 from './automation/assignment-rule.zod.js'; import type * as M195 from './security/queue.zod.js'; // --------------------------------------------------------------------------- -// 798 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 799 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -614,6 +614,7 @@ export type Iso_api_protocol__MarkAllNotificationsReadRequestSchema = Assert, z.infer< typeof M28.MarkAllNotificationsReadResponseSchema > >>; export type Iso_api_protocol__MarkNotificationsReadRequestSchema = Assert, z.infer< typeof M28.MarkNotificationsReadRequestSchema > >>; export type Iso_api_protocol__MarkNotificationsReadResponseSchema = Assert, z.infer< typeof M28.MarkNotificationsReadResponseSchema > >>; +export type Iso_api_protocol__MetadataAuthoringClientSchema = Assert, z.infer< typeof M28.MetadataAuthoringClientSchema > >>; export type Iso_api_protocol__MetadataAuthoringSurfaceSchema = Assert, z.infer< typeof M28.MetadataAuthoringSurfaceSchema > >>; export type Iso_api_protocol__PublishMetaItemRequestSchema = Assert, z.infer< typeof M28.PublishMetaItemRequestSchema > >>; export type Iso_api_protocol__PublishMetaItemResponseSchema = Assert, z.infer< typeof M28.PublishMetaItemResponseSchema > >>; @@ -1732,7 +1733,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 798 isomorphic pins', () => { + it('still declares all 799 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2658,7 +2659,12 @@ describe('ADR-0122 type-alias convention', () => { // and no transform — so it is pinned here // (Iso_ui_dataset__DatasetMeasurePolarity) rather than given a `…Parsed` // synonym. +1 added. - expect(pins).toHaveLength(798); + // 798 -> 799 is #27176: `MetadataAuthoringClientSchema` + // (`api/protocol.zod.ts`), the OAuth client an MCP build-door write names — + // a required id and an optional name, no default and no transform — so it + // is pinned here (Iso_api_protocol__MetadataAuthoringClientSchema) rather + // than given a `…Parsed` synonym. +1 added. + expect(pins).toHaveLength(799); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From 6ef6a96021bda9ee4776a897e03c2528392afb20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:57:30 +0000 Subject: [PATCH 2/4] feat(metadata): record the MCP surface and its OAuth client on draft provenance, and render them in Studio Claude-Session: https://claude.ai/code/session_01QUs3BthV9Hg8gmo8r35XcV Co-Authored-By: Claude --- .changeset/27176-mcp-draft-provenance.md | 22 ++++ content/docs/references/api/protocol.mdx | 38 ++++-- content/docs/references/index.mdx | 10 +- .../api.md | 2 +- packages/spec/api-surface/api.json | 2 + packages/spec/authorable-surface/api.json | 3 + packages/spec/declaration-map/api.json | 2 + packages/spec/export-origins/api.json | 2 + packages/spec/json-schema.manifest/api.json | 1 + .../src/api/meta-item-response-shapes.test.ts | 14 ++- .../src/preview/DraftChangesPanel.tsx | 67 +++++++++- ...aftChangesPanel.agentSource-27176.test.tsx | 116 ++++++++++++++++++ ...ataPillar.copilotDraftMarks-12725.test.tsx | 1 + ...tViewInspector.copilotMarks-12725.test.tsx | 6 +- .../studio-design/ObjectListViewInspector.tsx | 25 ++-- .../studio-design/StudioDesignSurface.tsx | 15 +-- .../studio-design/StudioDraftMark.test.ts | 63 +++++++--- .../views/studio-design/StudioDraftMark.tsx | 90 ++++++++++---- .../src/draftChanges-12725.test.ts | 4 + .../data-objectstack/src/metadata-client.ts | 9 +- ui/packages/i18n/src/locales/ar.ts | 4 + ui/packages/i18n/src/locales/de.ts | 4 + ui/packages/i18n/src/locales/en.ts | 4 + ui/packages/i18n/src/locales/es.ts | 4 + ui/packages/i18n/src/locales/fr.ts | 4 + ui/packages/i18n/src/locales/ja.ts | 4 + ui/packages/i18n/src/locales/ko.ts | 4 + ui/packages/i18n/src/locales/pt.ts | 4 + ui/packages/i18n/src/locales/ru.ts | 4 + ui/packages/i18n/src/locales/zh.ts | 4 + ui/packages/i18n/src/namespaces/studio/en.ts | 12 +- ui/packages/i18n/src/namespaces/studio/zh.ts | 12 +- 32 files changed, 461 insertions(+), 95 deletions(-) create mode 100644 .changeset/27176-mcp-draft-provenance.md create mode 100644 ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.agentSource-27176.test.tsx diff --git a/.changeset/27176-mcp-draft-provenance.md b/.changeset/27176-mcp-draft-provenance.md new file mode 100644 index 00000000000..a4e56c6a318 --- /dev/null +++ b/.changeset/27176-mcp-draft-provenance.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/rest': patch +'@object-ui/data-objectstack': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(spec): a draft's per-key provenance records writes made through the MCP build door, and the OAuth client that made them + +Clause-②: yes (widening) +Metadata-protocol: added + +A pending draft's `draftChanges` (the strict draft read's per-key provenance) could tell Studio's AI copilot from a person in Studio and from any other caller, but not an AI agent outside the console building through a deployment's MCP build door. Such a write was recorded as `api`, and nothing said which agent it was. + +- **Spec.** `MetadataAuthoringSurfaceSchema` gains `mcp`: an AI agent outside the console writing on a person's behalf through the MCP build door. A new `MetadataAuthoringClientSchema` (`{ id, name? }`) is the OAuth client the person consented to. A `draftChanges` entry carries it as an optional `client`, present only beside `by: 'mcp'`. `SaveMetaItemRequestSchema` gains an optional `client`, stated only by the MCP build door, in-process, from the token it verified. +- **The save.** A draft save with `surface: 'mcp'` stamps `by: 'mcp'` and its `client` on every key path it changes. A `client` beside any other surface, or of the wrong shape, is refused `400 INVALID_REQUEST` before anything is written. `actor` is still the person the agent writes for. +- **The HTTP doors.** `?surface=mcp` is refused `400 VALIDATION_ERROR`, as `?surface=copilot` is: only the MCP build door may state it. No HTTP door reads a `client`. +- **Studio.** The properties' draft chip and source line mark an `mcp` write as they mark the copilot's, and name the client: 「由 MCP 客户端「Claude」在 2 分钟前作为草稿加入」 / "Added as a draft by the MCP client "Claude" 2 min ago". A client that registered no name is shown as an unnamed MCP client, never by its id. The pending-changes sheet's drill-in lists which AI tools drafted part of the item, in all ten console locales. + +Nothing to do for existing callers: a save that states no surface is still recorded as `api`, and an entry without `client` reads as before. The MCP build door starts stating `mcp` and its client in a later change. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index e5cbe81cb32..d550d159dc7 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,7 +1,7 @@ --- title: Protocol schema — API Protocol reference navTitle: Protocol -description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 138 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 139 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilit ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetRecordFieldAccessResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetRecordFieldAccessResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetRecordFieldAccessResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringClientSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetRecordFieldAccessResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringClient, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -1458,7 +1458,7 @@ This schema accepts one of the following structures: | **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | -| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'studio' \| 'api'>; actor: string \| null; at: string }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | +| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `GetMetaItemResponse.sortability` @@ -1492,8 +1492,9 @@ One changed key path of the pending draft, and the last write that changed it. | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **path** | `string[]` | ✅ | The changed key path inside `item`: one object key per segment, from the body root down to the deepest key whose value the write changed, added or removed. `["fields", "renewal_count"]` is a field the write added; `["fields", "renewal_count", "label"]` is one property of it. A segment is the key exactly as stored, dots included (a permission set's `fields` map is keyed `object.field`), so a path is never one dotted string. An array is one value: its path ends at its key (`["columns"]`) and never carries an index, which an insert or a reorder would shift. Which elements of an array the draft added or dropped is read by comparing it with the published body's array; the entry answers only who last changed the array as a whole. `[]` is the whole body: the write that created the draft of an item with no published body. | -| **by** | `Enum<'copilot' \| 'studio' \| 'api'>` | ✅ | The authoring surface of the write that last changed this path: the `surface` that save declared (`SaveMetaItemRequestSchema.surface`), `api` when it declared none. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write and a Studio write of the same user carry the same `actor`. | +| **by** | `Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>` | ✅ | The authoring surface of the write that last changed this path: the `surface` that save declared (`SaveMetaItemRequestSchema.surface`), `api` when it declared none. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `mcp` is an AI agent outside the console writing on a person's behalf through the MCP build door; `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write, an MCP write and a Studio write of the same user carry the same `actor`. | | **actor** | `string \| null` | ✅ | The identity that write was recorded under: the save's `actor`, the value its history event carries. `null` when the write recorded none, never a sentinel string. | +| **client** | `{ id: string; name?: string }` | optional | The OAuth client that write was made by: the save's `client`, which only the MCP build door states, so it is present only beside `by: 'mcp'`. ABSENT on every other surface, and on an `mcp` entry whose save stated none: absence means "not recorded", never "no client". | | **at** | `string` | ✅ | When that write happened: an ISO-8601 instant in UTC, written with `Z` (the form `toISOString()` produces). An offset or a local time is refused. | @@ -2399,15 +2400,30 @@ Installed package with runtime lifecycle state | **readCount** | `number` | ✅ | Number of notifications marked as read | +--- + +## MetadataAuthoringClient + +The OAuth client an MCP build-door write was made by (see `MetadataAuthoringClientSchema`). + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | The OAuth client id the verified token was issued to. Opaque — compare it, never parse it. | +| **name** | `string` | optional | The client's registered display name (its OAuth `client_name`) when it registered one, as registered. Absent when it registered none; ⛔ never the id standing in for it. | + + --- ## MetadataAuthoringSurface -The authoring surface of a metadata write. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write and a Studio write of the same user carry the same `actor`. +The authoring surface of a metadata write. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `mcp` is an AI agent outside the console writing on a person's behalf through the MCP build door, as the OAuth client the person consented to; `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write, an MCP write and a Studio write of the same user carry the same `actor`. ### Allowed Values * `copilot` +* `mcp` * `studio` * `api` @@ -2788,7 +2804,15 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | **mode** | `Enum<'draft' \| 'publish'>` | optional | Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a pending draft overlay (`?mode=draft` on the REST door; the publish door promotes it later); `publish` or ABSENT writes straight to the live `active` row — the legacy default, kept so callers that predate the draft/publish split keep working. Any value other than `draft` is read as `publish`. | | **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent or `null` names no package. An `active` save then writes the row of the one package the item's active rows are bound to, as a save naming that package does, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none writes the env-local overlay (no package stamp); a `draft` save writes the draft of the one package the item's active rows are bound to (a pending change stays in the package it edits), else its package-unbound draft, adopted into that package — the draft the per-item publish naming no package promotes. For an item never published into a package it is the package-unbound draft, else the one package's draft, and with no draft at all the save creates the package-unbound draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages, as that publish is (see `PublishMetaItemRequestSchema.packageId`). The row written is also the one the parent-version resolution reads: an unpinned save takes its version as the parent (last-write-wins), a pinned one is judged against it, and the item read at the same scope serves it as `version`. | | **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch' \| 'external-import'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher, the duplicate-package door and the external-table import have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. | -| **surface** | `Enum<'copilot' \| 'studio' \| 'api'>` | optional | The authoring surface this save is made through (see `MetadataAuthoringSurfaceSchema`). A draft save (`mode: 'draft'`) stamps it as `by` on every key path it changes, the entries the strict draft read serves as `draftChanges`; an active save records no per-key provenance. ABSENT stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` and refuses every other value `400 VALIDATION_ERROR`, `copilot` included: `copilot` is stated only by an in-process AI tool calling the save, never by a remote caller, so an HTTP request cannot mark its own write as the copilot's. A value outside the set in-process is refused `400 INVALID_REQUEST`, before anything is written. | +| **surface** | `Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>` | optional | The authoring surface this save is made through (see `MetadataAuthoringSurfaceSchema`). A draft save (`mode: 'draft'`) stamps it as `by` on every key path it changes, the entries the strict draft read serves as `draftChanges`; an active save records no per-key provenance. ABSENT stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` and refuses every other value `400 VALIDATION_ERROR`, `copilot` and `mcp` included: `copilot` is stated only by an in-process AI tool calling the save and `mcp` only by the MCP build door after it verified the client's token, never by a remote caller, so an HTTP request cannot mark its own write as the copilot's or an MCP agent's. A value outside the set in-process is refused `400 INVALID_REQUEST`, before anything is written. | +| **client** | `{ id: string; name?: string }` | optional | The OAuth client this save is made by (see `MetadataAuthoringClientSchema`), stated only by the MCP build door, in-process, from the token it verified. Only with `surface: 'mcp'`: a `client` beside any other surface, or a malformed one, is refused `400 INVALID_REQUEST` before anything is written. A draft save stamps it as `client` beside `by: 'mcp'` on every key path it changes; an active save records it nowhere. No HTTP door reads it: the REST door and the dispatcher build this request member by member, so a `client` in a wire body or query is never read. | + +### Nested Shape: `SaveMetaItemRequest.client` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | The OAuth client id the verified token was issued to. Opaque — compare it, never parse it. | +| **name** | `string` | optional | The client's registered display name (its OAuth `client_name`) when it registered one, as registered. Absent when it registered none; ⛔ never the id standing in for it. | --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 4616c688802..fc034cb7933 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1559 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1560 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 439 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 440 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 185 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **202** | **1559** | 14 protocol modules | +| **Total** | **202** | **1560** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 439 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 440 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -91,7 +91,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringClient`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 7b162852b5c..9ebf534c149 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 448 | +| `api/` | 449 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index d00865aeaf2..81959d222f5 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -625,6 +625,8 @@ "MarkNotificationsReadRequestSchema (const)", "MarkNotificationsReadResponse (type)", "MarkNotificationsReadResponseSchema (const)", + "MetadataAuthoringClient (type)", + "MetadataAuthoringClientSchema (const)", "MetadataAuthoringSurface (type)", "MetadataAuthoringSurfaceSchema (const)", "MetadataBulkRegisterRequest (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 29946991cba..38206c9510b 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1057,6 +1057,8 @@ "api/MarkNotificationsReadRequest:ids", "api/MarkNotificationsReadResponse:readCount", "api/MarkNotificationsReadResponse:success", + "api/MetadataAuthoringClient:id", + "api/MetadataAuthoringClient:name", "api/MetadataBulkRegisterRequest:continueOnError", "api/MetadataBulkRegisterRequest:items", "api/MetadataBulkRegisterRequest:validate", @@ -1584,6 +1586,7 @@ "api/RuntimeAuthoringIssue:severity", "api/RuntimeAuthoringIssue:where", "api/SaveMetaItemRequest:actor", + "api/SaveMetaItemRequest:client", "api/SaveMetaItemRequest:force", "api/SaveMetaItemRequest:item", "api/SaveMetaItemRequest:mode", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index bea3663795c..855999bd636 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -461,6 +461,8 @@ "MarkNotificationsReadRequestSchema": "api/MarkNotificationsReadRequest", "MarkNotificationsReadResponse": "api/MarkNotificationsReadResponse", "MarkNotificationsReadResponseSchema": "api/MarkNotificationsReadResponse", + "MetadataAuthoringClient": "api/MetadataAuthoringClient", + "MetadataAuthoringClientSchema": "api/MetadataAuthoringClient", "MetadataAuthoringSurface": "api/MetadataAuthoringSurface", "MetadataAuthoringSurfaceSchema": "api/MetadataAuthoringSurface", "MetadataBulkRegisterRequest": "api/MetadataBulkRegisterRequest", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 722c60b65de..5397029f01a 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -600,6 +600,8 @@ "MarkNotificationsReadRequestSchema": "src/api/protocol.zod.ts#MarkNotificationsReadRequestSchema (const)", "MarkNotificationsReadResponse": "src/api/protocol.zod.ts#MarkNotificationsReadResponse (type)", "MarkNotificationsReadResponseSchema": "src/api/protocol.zod.ts#MarkNotificationsReadResponseSchema (const)", + "MetadataAuthoringClient": "src/api/protocol.zod.ts#MetadataAuthoringClient (type)", + "MetadataAuthoringClientSchema": "src/api/protocol.zod.ts#MetadataAuthoringClientSchema (const)", "MetadataAuthoringSurface": "src/api/protocol.zod.ts#MetadataAuthoringSurface (type)", "MetadataAuthoringSurfaceSchema": "src/api/protocol.zod.ts#MetadataAuthoringSurfaceSchema (const)", "MetadataBulkRegisterRequest": "src/api/metadata.zod.ts#MetadataBulkRegisterRequest (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 65d06d966ff..9fa67298c08 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -254,6 +254,7 @@ "api/MarkAllNotificationsReadResponse", "api/MarkNotificationsReadRequest", "api/MarkNotificationsReadResponse", + "api/MetadataAuthoringClient", "api/MetadataAuthoringSurface", "api/MetadataBulkRegisterRequest", "api/MetadataBulkResponse", diff --git a/packages/spec/src/api/meta-item-response-shapes.test.ts b/packages/spec/src/api/meta-item-response-shapes.test.ts index bc720b0f501..302861e6c9c 100644 --- a/packages/spec/src/api/meta-item-response-shapes.test.ts +++ b/packages/spec/src/api/meta-item-response-shapes.test.ts @@ -314,7 +314,9 @@ describe('the engine\'s verdict on a stored flow: `registration` beside the item * an SDK caller branches on, and deliberately not on the `_drafts` row. */ type DraftChange = NonNullable[number]; -export type DraftChangeByIsTheClosedSurfaceSet = Assert< Eq< DraftChange['by'], 'copilot' | 'studio' | 'api' > >; +export type DraftChangeByIsTheClosedSurfaceSet = Assert< Eq< DraftChange['by'], 'copilot' | 'mcp' | 'studio' | 'api' > >; +// #27176 — the OAuth client an `mcp` write was made by: an id, an optional name, and absent otherwise. +export type DraftChangeClientIsOptional = Assert< Eq< DraftChange['client'], { id: string; name?: string | undefined } | undefined > >; export type DraftChangePathIsAnArrayOfKeys = Assert< Eq< DraftChange['path'], string[] > >; export type DraftChangeActorIsIdOrNull = Assert< Eq< DraftChange['actor'], string | null > >; export type DraftChangesMayBeAbsent = Assert< Eq< undefined extends GetMetaItemResponse['draftChanges'] ? true : false, true > >; @@ -327,6 +329,8 @@ describe('`draftChanges` — which surface last changed each key of a pending dr { path: [], by: 'copilot', actor: 'usr_1', at: AT }, { path: ['columns'], by: 'studio', actor: 'usr_1', at: AT }, { path: ['fields', 'account.name', 'readable'], by: 'api', actor: null, at: AT }, + { path: ['label'], by: 'mcp', actor: 'usr_1', at: AT, client: { id: 'cli_1', name: 'Claude' } }, + { path: ['description'], by: 'mcp', actor: 'usr_1', at: AT }, ]; const DRAFT_READ = { type: 'view', name: 'loan_board', item: VIEW, version: 'v1', draftChanges: ENTRIES }; @@ -347,11 +351,17 @@ describe('`draftChanges` — which surface last changed each key of a pending dr }; it('refuses a surface outside the closed set, at the entry\'s `by`', () => { - for (const by of ['ai', 'user', 'mcp', 'Copilot']) { + for (const by of ['ai', 'user', 'MCP', 'Copilot']) { expect(issueOf([{ ...ENTRIES[0], by }])).toMatchObject({ code: 'invalid_value', path: ['draftChanges', 0, 'by'] }); } }); + it('#27176 — refuses a client with no id, an empty id or an empty name, at the entry\'s `client`', () => { + for (const client of [{}, { id: '' }, { id: 'cli_1', name: '' }, 'cli_1']) { + expect(issueOf([{ ...ENTRIES[3], client }])?.path?.slice(0, 3)).toEqual(['draftChanges', 0, 'client']); + } + }); + it('refuses a dotted-string path and an empty key segment — a path is an array of stored keys', () => { expect(issueOf([{ ...ENTRIES[1], path: 'fields.renewal_count' }])) .toMatchObject({ code: 'invalid_type', path: ['draftChanges', 0, 'path'] }); diff --git a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx index 9104a86ec6e..bd98043672f 100644 --- a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -44,7 +44,7 @@ import { } from '@object-ui/components'; import { useObjectTranslation } from '@object-ui/i18n'; import { useAdapter } from '@object-ui/react'; -import type { MetadataClient } from '@object-ui/data-objectstack'; +import { readDraftChanges, type DraftChange, type MetadataClient } from '@object-ui/data-objectstack'; // The `/meta` URL-spelling fold (objectstack#7894, objectstack-ai/objectstack#8424), applied once in // `listPendingDrafts` below. // @@ -239,6 +239,19 @@ async function fetchItemBody( name: string, opts: { draft?: boolean; packageId?: string | null; member?: string } = {}, ): Promise | null> { + return (await fetchItemRead(type, name, opts))?.body ?? null; +} + +/** + * The item read {@link fetchItemBody} unwraps, with the `draftChanges` the + * strict draft read serves beside `item` (objectstack#24298) — `null` when it + * served none ("not recorded"), and always on the published read. + */ +async function fetchItemRead( + type: string, + name: string, + opts: { draft?: boolean; packageId?: string | null; member?: string } = {}, +): Promise<{ body: Record | null; changes: DraftChange[] | null } | null> { // `type` is already the canonical singular — folded in `listPendingDrafts`. const params: string[] = []; if (opts.draft) params.push('state=draft'); @@ -254,7 +267,32 @@ async function fetchItemBody( ); if (res.status === 404) return null; if (!res.ok) throw new Error(`HTTP ${res.status}`); - return unwrapItem(await res.json()); + const payload = await res.json(); + return { body: unwrapItem(payload), changes: opts.draft ? readDraftChanges(payload) : null }; +} + +/** + * objectstack#27176 — who of the AI tools drafted part of this item, as the + * drill-in names them: the AI copilot once, and each MCP client once (by its + * OAuth client id; a client that registered no name, and an `mcp` write that + * recorded no client, fold into one unnamed entry). In the order the entries + * first name them. `studio` and `api` writes are a person's or another + * caller's, and are not listed: the row's own `updatedBy` already names who. + */ +export type DraftAgentSource = { by: 'copilot' } | { by: 'mcp'; client?: string }; + +export function draftAgentSources(changes: readonly DraftChange[] | null | undefined): DraftAgentSource[] { + const out: DraftAgentSource[] = []; + const seen = new Set(); + for (const change of changes ?? []) { + if (change.by !== 'copilot' && change.by !== 'mcp') continue; + const name = change.by === 'mcp' ? change.client?.name : undefined; + const key = change.by === 'copilot' ? 'copilot' : name ? `mcp:${change.client!.id}` : 'mcp'; + if (seen.has(key)) continue; + seen.add(key); + out.push(change.by === 'copilot' ? { by: 'copilot' } : name ? { by: 'mcp', client: name } : { by: 'mcp' }); + } + return out; } export interface EntryChangeDetail { @@ -677,6 +715,7 @@ function EntryDetail({ }) { const { t } = useObjectTranslation(); const [detail, setDetail] = useState(null); + const [agents, setAgents] = useState([]); const [error, setError] = useState(null); const [candidates, setCandidates] = useState>([]); const [recordCount, setRecordCount] = useState(undefined); @@ -703,7 +742,7 @@ function EntryDetail({ let cancelled = false; (async () => { try { - const [published, draft] = await Promise.all([ + const [published, draftRead] = await Promise.all([ // A NEW item has no published body: not asked // (objectstack-ai/objectstack#25894). An item the // classification could not answer still 404s here when it is new — @@ -711,14 +750,16 @@ function EntryDetail({ classifiedNew ? null : fetchItemBody(entry.type, entry.name, { packageId: entry.packageId, member: entry.member }), - fetchItemBody(entry.type, entry.name, { + fetchItemRead(entry.type, entry.name, { draft: true, packageId: entry.packageId, member: entry.member, }), ]); if (cancelled) return; + const draft = draftRead?.body ?? null; const computed = computeChangeDetail(published, draft); + setAgents(draftAgentSources(draftRead?.changes)); setDetail(computed); // Only an object's added fields can be backfilled (objectui#12929). setCandidates( @@ -773,6 +814,23 @@ function EntryDetail({ const { fields, keys, generated, isNew } = detail; const hasKeyRows = keys.added.length > 0 || keys.changed.length > 0 || keys.removed.length > 0; + // objectstack#27176 — which AI tools drafted part of this item, one line each. + const agentLines = agents.map((source) => ( +

+ {source.by === 'copilot' + ? t('preview.changes.draftedByCopilot', { defaultValue: 'Includes changes drafted by the AI copilot' }) + : source.client + ? t('preview.changes.draftedByMcpClient', { + client: source.client, + defaultValue: 'Includes changes drafted by the MCP client “{{client}}”', + }) + : t('preview.changes.draftedByMcp', { defaultValue: 'Includes changes drafted by an MCP client' })} +

+ )); if (isEmptyChangeDetail(detail)) { return (

@@ -785,6 +843,7 @@ function EntryDetail({ return (

+ {agentLines} {fields?.added.map((name) => { const candidate = backfill ? candidates.find((c) => c.field === name) : undefined; const choice: DraftBackfillEntry = { object: entry.name, field: name }; diff --git a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.agentSource-27176.test.tsx b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.agentSource-27176.test.tsx new file mode 100644 index 00000000000..66b7a49ab6f --- /dev/null +++ b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.agentSource-27176.test.tsx @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack-ai/objectstack#27176 — the pending-changes sheet names the AI + * tools that drafted part of an item: the AI copilot, and an agent building + * through the MCP build door by the OAuth client the person consented to. + * + * The drill-in already sends the strict draft read (`?state=draft`), which + * serves the draft's per-key provenance (`draftChanges`) beside `item`; the + * line reads it from there. + * + * Pinned: + * - an `mcp` entry with a named client reads "drafted by the MCP client + * “Claude”"; the copilot reads its own line; each source once; + * - an `mcp` client with no registered name is unnamed, never its id; + * - CONTROL: `studio` / `api` entries, and a read with no `draftChanges`, + * add no line. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (_k: string, o?: Record & { defaultValue?: string }) => + (o?.defaultValue ?? _k).replace(/\{\{(\w+)\}\}/g, (_m, name: string) => String(o?.[name] ?? '')), + language: 'en', + }), +})); + +import { DraftChangesPanel, draftAgentSources } from '../DraftChangesPanel'; + +const PKG = 'com.x'; +const AT = '2026-10-28T10:00:00.000Z'; +const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + +let draftChanges: unknown = undefined; + +const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/_drafts')) return ok({ drafts: [{ type: 'object', name: 'st_ticket', packageId: PKG, label: 'Ticket' }] }); + if (/\/meta\/object(\?|$)/.test(url)) return ok([{ name: 'st_ticket' }]); + if (url.includes('state=draft')) { + return ok({ + type: 'object', + name: 'st_ticket', + item: { name: 'st_ticket', label: 'Ticket', fields: { a: { type: 'text' } } }, + ...(draftChanges !== undefined ? { draftChanges } : {}), + }); + } + return ok({ type: 'object', name: 'st_ticket', item: { name: 'st_ticket', label: 'Ticket', fields: {} } }); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + fetchMock.mockClear(); + draftChanges = undefined; +}); + +async function renderConfirm() { + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + render( {}} packageId={PKG} onPublish={() => {}} posture="confirm" />); + await waitFor(() => expect(screen.getByTestId('draft-entry-detail')).toBeInTheDocument()); +} + +const agentLines = () => screen.queryAllByTestId('draft-entry-agent').map((el) => el.textContent); + +describe('objectstack#27176 — the drill-in names the AI tools that drafted the item', () => { + it('an MCP client by its registered name, and the copilot, each once', async () => { + draftChanges = [ + { path: ['fields', 'a'], by: 'mcp', actor: 'u_1', at: AT, client: { id: 'cli_1', name: 'Claude' } }, + { path: ['label'], by: 'copilot', actor: 'u_1', at: AT }, + { path: ['fields', 'b'], by: 'mcp', actor: 'u_1', at: AT, client: { id: 'cli_1', name: 'Claude' } }, + ]; + await renderConfirm(); + expect(agentLines()).toEqual([ + 'Includes changes drafted by the MCP client “Claude”', + 'Includes changes drafted by the AI copilot', + ]); + }); + + it('a client with no registered name is unnamed, never its id', async () => { + draftChanges = [{ path: ['fields', 'a'], by: 'mcp', actor: 'u_1', at: AT, client: { id: 'cli_opaque' } }]; + await renderConfirm(); + expect(agentLines()).toEqual(['Includes changes drafted by an MCP client']); + expect(screen.queryByText(/cli_opaque/)).toBeNull(); + }); + + it('CONTROL: studio and api writes, or no recorded provenance, add no line', async () => { + draftChanges = [{ path: ['fields', 'a'], by: 'studio', actor: 'u_1', at: AT }]; + await renderConfirm(); + expect(agentLines()).toEqual([]); + cleanup(); + draftChanges = undefined; + await renderConfirm(); + expect(agentLines()).toEqual([]); + }); +}); + +describe('draftAgentSources', () => { + it('folds an unnamed client and an mcp write with none into one unnamed source; two named clients stay two', () => { + expect( + draftAgentSources([ + { path: ['a'], by: 'mcp', actor: null, at: AT, client: { id: 'cli_1' } }, + { path: ['b'], by: 'mcp', actor: null, at: AT }, + { path: ['c'], by: 'mcp', actor: null, at: AT, client: { id: 'cli_2', name: 'Cursor' } }, + { path: ['d'], by: 'mcp', actor: null, at: AT, client: { id: 'cli_3', name: 'Claude' } }, + { path: ['e'], by: 'api', actor: null, at: AT }, + ]), + ).toEqual([{ by: 'mcp' }, { by: 'mcp', client: 'Cursor' }, { by: 'mcp', client: 'Claude' }]); + expect(draftAgentSources(null)).toEqual([]); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.copilotDraftMarks-12725.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.copilotDraftMarks-12725.test.tsx index 4af2bc5b906..8f74f2bafd8 100644 --- a/ui/packages/app-shell/src/views/studio-design/DataPillar.copilotDraftMarks-12725.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.copilotDraftMarks-12725.test.tsx @@ -117,6 +117,7 @@ async function openRail(label: string, name: string): Promise { } const SOURCE_2_MIN = tFormat('engine.studio.draftMark.source', 'en-US', { + agent: tFormat('engine.studio.draftMark.agent.copilot', 'en-US', {}), when: tFormat('engine.studio.draftMark.minutesAgo', 'en-US', { n: 2 }), }); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.copilotMarks-12725.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.copilotMarks-12725.test.tsx index 4d370289e19..c20bac7674e 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.copilotMarks-12725.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.copilotMarks-12725.test.tsx @@ -200,6 +200,7 @@ describe('ObjectListViewInspector — the copilot’s draft marks (objectui#1272 tFormat('engine.studio.draftMark.sourceItems', 'en-US', { items: '"Reader", "Due date"', count: 2, + agent: tFormat('engine.studio.draftMark.agent.copilot', 'en-US', {}), when: tFormat('engine.studio.draftMark.minutesAgo', 'en-US', { n: 2 }), }), ); @@ -216,7 +217,10 @@ describe('ObjectListViewInspector — the copilot’s draft marks (objectui#1272 expect(chipsOf(panel)).toEqual(['kanban.titleField']); expect(markedElements(panel)).toEqual([]); expect(within(panel).getByTestId('studio-draft-source')).toHaveTextContent( - tFormat('engine.studio.draftMark.source', 'en-US', { when: tFormat('engine.studio.draftMark.minutesAgo', 'en-US', { n: 5 }) }), + tFormat('engine.studio.draftMark.source', 'en-US', { + agent: tFormat('engine.studio.draftMark.agent.copilot', 'en-US', {}), + when: tFormat('engine.studio.draftMark.minutesAgo', 'en-US', { n: 5 }), + }), ); }); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx index 1569e15919f..7b1716bc944 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx @@ -86,10 +86,11 @@ import { issueRefusal, plainRefusal } from './metadataError.js'; import { isStudioHiddenSystemField } from './studioHiddenSystemField.js'; import { addedArrayElements, - copilotDraftMarkOf, + agentDraftMarkOf, DraftChip, DraftSourceLine, - type CopilotDraftMark, + isAgentDraftSurface, + type AgentDraftMark, } from './StudioDraftMark.js'; type Row = Record; @@ -373,7 +374,7 @@ export function useObjectListViewDraft({ const draftChanges = draft ? readDraftChanges(draftResp) : null; const marksNeedPublished = !!draftChanges && - draftChanges.some((c) => c.by === 'copilot') && + draftChanges.some((c) => isAgentDraftSurface(c.by)) && !draftChanges.some((c) => c.path.length === 0); // A failure of that added read costs the element marks only, never the // editor: the draft is the buffer either way. @@ -609,12 +610,12 @@ function listViewTitle(target: ListViewTarget, item: Row, objectText: string, lo /** objectui#12725 — the copilot's marks on the kanban card section's settings. */ interface KanbanCardMarks { - fields: CopilotDraftMark | null; + fields: AgentDraftMark | null; /** The card fields (`kanban.columns`) the copilot added, by name. */ addedFields: string[]; - title: CopilotDraftMark | null; - open: CopilotDraftMark | null; - groupBy: CopilotDraftMark | null; + title: AgentDraftMark | null; + open: AgentDraftMark | null; + groupBy: AgentDraftMark | null; } const SORT_ID = 'studio-list-view-sort'; @@ -925,10 +926,10 @@ export function ObjectListViewInspector({ // objectui#12725 — the copilot's draft marks. A key path is the item's own: // a record-shaped view keeps its list body under `config`. const bodyPath = isPlainObject(working.config) ? ['config'] : []; - const markOf = (...path: string[]) => copilotDraftMarkOf(draftChanges, [...bodyPath, ...path]); + const markOf = (...path: string[]) => agentDraftMarkOf(draftChanges, [...bodyPath, ...path]); // `undefined`: the published body is unknown, so no element is marked. const publishedBody = published ? listBodyOf(published) : published; - const addedNames = (mark: CopilotDraftMark | null, draftArray: unknown, publishedArray: unknown): string[] => + const addedNames = (mark: AgentDraftMark | null, draftArray: unknown, publishedArray: unknown): string[] => mark && publishedBody !== undefined ? addedArrayElements(draftArray, publishedArray) .map((c) => colFieldName(c) ?? (typeof c === 'string' ? c : '')) @@ -953,8 +954,8 @@ export function ObjectListViewInspector({ columnsMark, filterMark, sortMark, - ].filter((m): m is CopilotDraftMark => m !== null); - const latestMark = drawnMarks.reduce( + ].filter((m): m is AgentDraftMark => m !== null); + const latestMark = drawnMarks.reduce( (latest, m) => (!latest || Date.parse(m.at) > Date.parse(latest.at) ? m : latest), null, ); @@ -1133,7 +1134,7 @@ export function ObjectListViewInspector({
{latestMark && ( // objectui#12725 — the 来源 line: who drafted the marked settings, and when. - + )} )} 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 74773eb4aa1..f36bcb4e28a 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -64,7 +64,7 @@ import { STUDIO_PKG_READONLY_WORD, } from './studio-tokens.js'; import { StudioInspector } from './StudioInspector.js'; -import { copilotDraftMarkOf, DraftChip, DraftSourceLine } from './StudioDraftMark.js'; +import { agentDraftMarkOf, DraftChip, DraftSourceLine } from './StudioDraftMark.js'; import { StudioItemHeader, StudioStatusChip, @@ -6968,9 +6968,10 @@ export function DataPillar({ setCurrent(object); }; // objectui#12725 — the copilot's mark on the selected field: its key path is - // `["fields", NAME]` in the object body, and only a `copilot` write answers. - const fieldCopilotMark = - objLoaded && fieldSel?.kind === 'field' ? copilotDraftMarkOf(objDraftChanges, ['fields', fieldSel.id]) : null; + // `["fields", NAME]` in the object body, and only a `copilot` write answers — + // or, objectstack#27176, an `mcp` one: an agent through the MCP build door. + const fieldAgentMark = + objLoaded && fieldSel?.kind === 'field' ? agentDraftMarkOf(objDraftChanges, ['fields', fieldSel.id]) : null; useHeldEditReport(onHeldEditChange, objHeld?.clause ?? null); // The live request's apply (see `requestedSurface` above). objectstack#26845 // — another object's load replaces the buffer a held edit lives in, so it @@ -7720,7 +7721,7 @@ export function DataPillar({ onClose={() => setFieldSel(null)} closeLabel={t('engine.studio.close', locale)} // objectui#12725 — the copilot's 草稿 chip on a field it changed. - actions={fieldCopilotMark ? : undefined} + actions={fieldAgentMark ? : undefined} // objectui#12453 — a read-only package saves no draft: no footer. footer={readOnly ? null : t('engine.studio.inspector.draftFooter', locale)} bodyClassName="flex min-h-0 flex-1 flex-col" @@ -7761,10 +7762,10 @@ export function DataPillar({ ); // objectui#12725 — the 来源 line under the field's properties. - const railContent = fieldCopilotMark ? ( + const railContent = fieldAgentMark ? ( <> {fieldRailContent} - + ) : ( fieldRailContent diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDraftMark.test.ts b/ui/packages/app-shell/src/views/studio-design/StudioDraftMark.test.ts index 01ae27fff93..6a3bfd02866 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDraftMark.test.ts +++ b/ui/packages/app-shell/src/views/studio-design/StudioDraftMark.test.ts @@ -3,12 +3,13 @@ /** * objectui#12725 (item 3) — the copilot's draft marks: when one is drawn for a * key, which array elements count as added, and the source sentence's copy in - * both designer locales. + * both designer locales. objectstack#27176 — the same marks for an agent + * through the MCP build door, naming its OAuth client. */ import { describe, expect, it } from 'vitest'; import type { DraftChange } from '@object-ui/data-objectstack'; -import { addedArrayElements, copilotDraftMarkOf, draftSourceText } from './StudioDraftMark'; +import { addedArrayElements, agentDraftMarkOf, draftSourceText, type AgentDraftMark } from './StudioDraftMark'; // The `engine.studio.*` rows this suite reads in zh live in `@object-ui/i18n`'s // `studio` namespace, whose `zh` catalogue is fetched on demand @@ -21,26 +22,36 @@ await loadStudioCatalogue('zh'); const NOW = Date.parse('2026-10-19T10:00:00.000Z'); const ago = (minutes: number) => new Date(NOW - minutes * 60_000).toISOString(); const entry = (path: string[], by: DraftChange['by'], minutes = 2): DraftChange => ({ path, by, actor: 'usr_1', at: ago(minutes) }); +const copilot = (at: string): AgentDraftMark => ({ at, by: 'copilot' }); +const CLAUDE = { id: 'cli_claude', name: 'Claude' }; -describe('copilotDraftMarkOf — only a copilot write marks a key (objectui#12725)', () => { +describe('agentDraftMarkOf — only a copilot write marks a key (objectui#12725)', () => { it('the answering entry by the copilot marks the key, with its `at`', () => { - expect(copilotDraftMarkOf([entry(['fields', 'x'], 'copilot')], ['fields', 'x'])).toEqual({ at: ago(2) }); - expect(copilotDraftMarkOf([entry([], 'copilot', 7)], ['fields', 'x'])).toEqual({ at: ago(7) }); + expect(agentDraftMarkOf([entry(['fields', 'x'], 'copilot')], ['fields', 'x'])).toEqual({ at: ago(2), by: 'copilot' }); + expect(agentDraftMarkOf([entry([], 'copilot', 7)], ['fields', 'x'])).toEqual({ at: ago(7), by: 'copilot' }); }); it.each(['studio', 'api'] as const)('an answering entry through `%s` draws no mark', (by) => { - expect(copilotDraftMarkOf([entry(['fields', 'x'], by)], ['fields', 'x'])).toBeNull(); + expect(agentDraftMarkOf([entry(['fields', 'x'], by)], ['fields', 'x'])).toBeNull(); // The deeper studio write answers, not the copilot's whole-body one. - expect(copilotDraftMarkOf([entry([], 'copilot'), entry(['fields', 'x'], by)], ['fields', 'x'])).toBeNull(); + expect(agentDraftMarkOf([entry([], 'copilot'), entry(['fields', 'x'], by)], ['fields', 'x'])).toBeNull(); }); it('a copilot entry under the key marks it; the latest copilot `at` is the one read', () => { const changes = [entry(['fields', 'x'], 'studio', 1), entry(['fields', 'x', 'label'], 'copilot', 4), entry(['fields', 'x', 'type'], 'copilot', 3)]; - expect(copilotDraftMarkOf(changes, ['fields', 'x'])).toEqual({ at: ago(3) }); + expect(agentDraftMarkOf(changes, ['fields', 'x'])).toEqual({ at: ago(3), by: 'copilot' }); }); it('CONTROL: absent `draftChanges` draws no mark', () => { - expect(copilotDraftMarkOf(null, ['fields', 'x'])).toBeNull(); + expect(agentDraftMarkOf(null, ['fields', 'x'])).toBeNull(); + }); + + it('objectstack#27176: an `mcp` write marks the key too, with its client; the latest agent write names the source', () => { + const mcp = { ...entry(['fields', 'x'], 'mcp', 1), client: CLAUDE }; + expect(agentDraftMarkOf([mcp], ['fields', 'x'])).toEqual({ at: ago(1), by: 'mcp', client: CLAUDE }); + expect(agentDraftMarkOf([entry(['fields', 'x'], 'mcp')], ['fields', 'x'])).toEqual({ at: ago(2), by: 'mcp' }); + const mixed = [entry(['fields', 'x', 'label'], 'copilot', 5), { ...entry(['fields', 'x', 'type'], 'mcp', 1), client: CLAUDE }]; + expect(agentDraftMarkOf(mixed, ['fields', 'x'])).toEqual({ at: ago(1), by: 'mcp', client: CLAUDE }); }); }); @@ -57,19 +68,37 @@ describe('addedArrayElements — the draft array against the published one (obje describe('draftSourceText — the source sentence (objectui#12725)', () => { it('reads the ruled zh copy, N minutes from `at`', () => { - expect(draftSourceText(ago(2), 'zh-CN', [], NOW)).toBe('由 AI 副驾在 2 分钟前作为草稿加入'); - expect(draftSourceText(ago(2), 'zh-CN', ['读者', '应还日期'], NOW)).toBe('「读者」「应还日期」 2 项由 AI 副驾在 2 分钟前作为草稿加入'); + expect(draftSourceText(copilot(ago(2)), 'zh-CN', [], NOW)).toBe('由 AI 副驾在 2 分钟前作为草稿加入'); + expect(draftSourceText(copilot(ago(2)), 'zh-CN', ['读者', '应还日期'], NOW)).toBe('「读者」「应还日期」 2 项由 AI 副驾在 2 分钟前作为草稿加入'); }); it('reads the en copy, and moves to hours and days past an hour', () => { - expect(draftSourceText(ago(2), 'en-US', [], NOW)).toBe('Added as a draft by the AI copilot 2 min ago'); - expect(draftSourceText(ago(125), 'en-US', [], NOW)).toBe('Added as a draft by the AI copilot 2 h ago'); - expect(draftSourceText(ago(3 * 1440), 'zh-CN', [], NOW)).toBe('由 AI 副驾在 3 天前作为草稿加入'); - expect(draftSourceText(ago(2), 'en-US', ['Reader', 'Due date'], NOW)).toBe('"Reader", "Due date": 2 added as a draft by the AI copilot 2 min ago'); + expect(draftSourceText(copilot(ago(2)), 'en-US', [], NOW)).toBe('Added as a draft by the AI copilot 2 min ago'); + expect(draftSourceText(copilot(ago(125)), 'en-US', [], NOW)).toBe('Added as a draft by the AI copilot 2 h ago'); + expect(draftSourceText(copilot(ago(3 * 1440)), 'zh-CN', [], NOW)).toBe('由 AI 副驾在 3 天前作为草稿加入'); + expect(draftSourceText(copilot(ago(2)), 'en-US', ['Reader', 'Due date'], NOW)).toBe('"Reader", "Due date": 2 added as a draft by the AI copilot 2 min ago'); }); it('under a minute reads "just now", never "0 minutes ago"', () => { - expect(draftSourceText(ago(0), 'zh-CN', [], NOW)).toBe('由 AI 副驾刚刚作为草稿加入'); - expect(draftSourceText(ago(0), 'en-US', [], NOW)).toBe('Added as a draft by the AI copilot just now'); + expect(draftSourceText(copilot(ago(0)), 'zh-CN', [], NOW)).toBe('由 AI 副驾刚刚作为草稿加入'); + expect(draftSourceText(copilot(ago(0)), 'en-US', [], NOW)).toBe('Added as a draft by the AI copilot just now'); + }); +}); + +describe('draftSourceText — an MCP write names its client (objectstack#27176)', () => { + it('names the client by its registered name, in both designer locales', () => { + const mark: AgentDraftMark = { at: ago(2), by: 'mcp', client: CLAUDE }; + expect(draftSourceText(mark, 'zh-CN', [], NOW)).toBe('由 MCP 客户端「Claude」在 2 分钟前作为草稿加入'); + expect(draftSourceText(mark, 'en-US', [], NOW)).toBe('Added as a draft by the MCP client "Claude" 2 min ago'); + expect(draftSourceText(mark, 'zh-CN', ['读者'], NOW)).toBe('「读者」 1 项由 MCP 客户端「Claude」在 2 分钟前作为草稿加入'); + expect(draftSourceText({ ...mark, at: ago(0) }, 'en-US', [], NOW)).toBe('Added as a draft by the MCP client "Claude" just now'); + }); + + it('a client with no registered name, or none recorded, is an MCP client unnamed — never its id', () => { + for (const mark of [{ at: ago(2), by: 'mcp', client: { id: 'cli_opaque' } }, { at: ago(2), by: 'mcp' }] as AgentDraftMark[]) { + expect(draftSourceText(mark, 'zh-CN', [], NOW)).toBe('由 MCP 客户端在 2 分钟前作为草稿加入'); + expect(draftSourceText(mark, 'en-US', [], NOW)).toBe('Added as a draft by an MCP client 2 min ago'); + expect(draftSourceText(mark, 'en-US', [], NOW)).not.toContain('cli_opaque'); + } }); }); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDraftMark.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDraftMark.tsx index 643c8cf2ab9..8b05c095b89 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDraftMark.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDraftMark.tsx @@ -4,25 +4,29 @@ * objectui#12725 (item 3) — the copilot's draft marks on Studio's properties: * the 草稿 chip on a field or a view setting the AI copilot changed, and the * 来源 line 「由 AI 副驾在 N 分钟前作为草稿加入」, as the StudioRailProps - * artboard draws them. + * artboard draws them. objectstack#27176 draws the same marks for an agent + * building through the MCP build door, naming the OAuth client the person + * consented to: 「由 MCP 客户端「Claude」在 N 分钟前作为草稿加入」. * * ## What they read * * The pending draft's per-key provenance, `draftChanges`, which the strict * draft read serves beside `item` (objectstack#24298, written by - * objectstack#24395): one `{ path, by, actor, at }` per changed key path. The + * objectstack#24395): one `{ path, by, actor, at, client? }` per changed key path. The * entry that answers for a key is the one `governingDraftChange` finds (the * longest prefix, `@object-ui/data-objectstack`, the one reader of it). * * ## When a mark is drawn * - * Only for `by: 'copilot'`. A key whose answering entry is `studio` (a person - * in Studio) or `api` (any other caller) draws nothing, and so does a draft - * whose read recorded no `draftChanges` at all (absent is "not recorded"). - * Besides the answering entry, an entry UNDER the key counts too: the copilot - * changing one property of a published field records `["fields", NAME, - * "label"]`, which no ancestor of `["fields", NAME]` answers for, and that - * field is still one the copilot changed. + * Only for an AI tool writing on a person's behalf: `by: 'copilot'` (Studio's + * AI copilot) or `by: 'mcp'` (an agent outside the console, through the MCP + * build door). A key whose answering entry is `studio` (a person in Studio) or + * `api` (any other caller) draws nothing, and so does a draft whose read + * recorded no `draftChanges` at all (absent is "not recorded"). Besides the + * answering entry, an entry UNDER the key counts too: the copilot changing one + * property of a published field records `["fields", NAME, "label"]`, which no + * ancestor of `["fields", NAME]` answers for, and that field is still one the + * copilot changed. * * The marks describe the draft as it was READ: a later edit made here is * attributed by the framework on save and shows from the next read. @@ -33,26 +37,56 @@ import { cn } from '@object-ui/components'; import { draftChangesUnder, governingDraftChange, type DraftChange } from '@object-ui/data-objectstack'; import { isZhLocale, t, tFormat } from '../metadata-admin/i18n.js'; -/** The copilot's mark on one key: when the copilot last changed it. */ -export interface CopilotDraftMark { +/** The surfaces whose writes Studio marks: an AI tool writing on a person's behalf. */ +export type AgentDraftSurface = Extract; + +/** Whether a `draftChanges` entry's surface is one Studio marks. */ +export function isAgentDraftSurface(by: DraftChange['by']): by is AgentDraftSurface { + return by === 'copilot' || by === 'mcp'; +} + +/** + * An AI tool's mark on one key: when it last changed it, through which surface, + * and (for `mcp`, when the write recorded one) the OAuth client it was. + */ +export interface AgentDraftMark { at: string; + by: AgentDraftSurface; + client?: DraftChange['client']; } /** - * The copilot's mark on the key at `path`, or `null` when no copilot write - * answers for it — see the module docblock. With several copilot entries (the - * key's own and some under it), the latest `at` is the one the 来源 line reads. + * The AI tool's mark on the key at `path`, or `null` when no copilot or MCP + * write answers for it — see the module docblock. With several such entries + * (the key's own and some under it), the latest `at` is the one the 来源 line + * reads, and its surface and client are the ones it names. */ -export function copilotDraftMarkOf( +export function agentDraftMarkOf( changes: readonly DraftChange[] | null | undefined, path: readonly string[], -): CopilotDraftMark | null { +): AgentDraftMark | null { let latest: DraftChange | null = null; for (const change of [governingDraftChange(changes, path), ...draftChangesUnder(changes, path)]) { - if (!change || change.by !== 'copilot') continue; + if (!change || !isAgentDraftSurface(change.by)) continue; if (!latest || Date.parse(change.at) > Date.parse(latest.at)) latest = change; } - return latest ? { at: latest.at } : null; + if (!latest || !isAgentDraftSurface(latest.by)) return null; + return { at: latest.at, by: latest.by, ...(latest.client ? { client: latest.client } : {}) }; +} + +/** + * Who drafted it, as the 来源 sentence names them: the AI copilot, the MCP + * client by its registered name, or — a client that registered none, or an + * `mcp` write that recorded none — an MCP client unnamed. ⛔ Never the client + * id standing in for a name: it is an opaque token, not something a person + * recognises. + */ +export function draftAgentText(mark: Pick, locale: string): string { + if (mark.by === 'copilot') return t('engine.studio.draftMark.agent.copilot', locale); + const name = mark.client?.name; + return name + ? tFormat('engine.studio.draftMark.agent.mcpClient', locale, { client: name }) + : t('engine.studio.draftMark.agent.mcp', locale); } /** @@ -86,24 +120,26 @@ function agoText(at: string, locale: string, now: number): string | null { /** * The 来源 sentence: 「由 AI 副驾在 2 分钟前作为草稿加入」, or, naming the * added elements of an array setting, 「「读者」「应还日期」 2 项由 AI 副驾在 2 - * 分钟前作为草稿加入」. + * 分钟前作为草稿加入」. An MCP write names its client the same way: + * 「由 MCP 客户端「Claude」在 2 分钟前作为草稿加入」. */ export function draftSourceText( - at: string, + mark: AgentDraftMark, locale: string, items: readonly string[] = [], now: number = Date.now(), ): string { - const ago = agoText(at, locale, now); + const ago = agoText(mark.at, locale, now); + const agent = draftAgentText(mark, locale); if (items.length === 0) { return ago - ? tFormat('engine.studio.draftMark.source', locale, { when: ago }) - : t('engine.studio.draftMark.sourceJustNow', locale); + ? tFormat('engine.studio.draftMark.source', locale, { agent, when: ago }) + : tFormat('engine.studio.draftMark.sourceJustNow', locale, { agent }); } const named = items .map((label) => tFormat('engine.studio.draftMark.item', locale, { label })) .join(isZhLocale(locale) ? '' : ', '); - const vars = { items: named, count: items.length }; + const vars = { items: named, count: items.length, agent }; return ago ? tFormat('engine.studio.draftMark.sourceItems', locale, { ...vars, when: ago }) : tFormat('engine.studio.draftMark.sourceItemsJustNow', locale, vars); @@ -132,8 +168,8 @@ export function DraftChip({ locale, className, testId = 'studio-draft-chip', of } /** The 来源 block: its label, then the sentence ({@link draftSourceText}). */ -export function DraftSourceLine({ at, locale, items, className, testId = 'studio-draft-source' }: { - at: string; +export function DraftSourceLine({ mark, locale, items, className, testId = 'studio-draft-source' }: { + mark: AgentDraftMark; locale: string; items?: readonly string[]; className?: string; @@ -142,7 +178,7 @@ export function DraftSourceLine({ at, locale, items, className, testId = 'studio return (

{t('engine.studio.draftMark.sourceLabel', locale)}

-

{draftSourceText(at, locale, items)}

+

{draftSourceText(mark, locale, items)}

); } diff --git a/ui/packages/data-objectstack/src/draftChanges-12725.test.ts b/ui/packages/data-objectstack/src/draftChanges-12725.test.ts index ff42ef28437..df436da70c6 100644 --- a/ui/packages/data-objectstack/src/draftChanges-12725.test.ts +++ b/ui/packages/data-objectstack/src/draftChanges-12725.test.ts @@ -118,3 +118,7 @@ describe('MetadataClient.save — the authoring surface, `?surface=` (objectui#1 // it. The option's type is the spec's set minus `copilot`, so it cannot be sent. // @ts-expect-error -- `copilot` is not a surface this client may send. export const copilotRefused: MetadataClientSaveOptions = { surface: 'copilot' }; + +// objectstack#27176 — `mcp` likewise: only the MCP build door states it, in-process. +// @ts-expect-error -- `mcp` is not a surface this client may send. +export const mcpRefused: MetadataClientSaveOptions = { surface: 'mcp' }; diff --git a/ui/packages/data-objectstack/src/metadata-client.ts b/ui/packages/data-objectstack/src/metadata-client.ts index 7d457a4dfd0..f63838d262c 100644 --- a/ui/packages/data-objectstack/src/metadata-client.ts +++ b/ui/packages/data-objectstack/src/metadata-client.ts @@ -452,11 +452,12 @@ export interface MetadataClientSaveOptions { * edit there is told apart from the AI copilot's (objectstack#24395). * * DERIVED from the spec's authoring-surface set (the one `draftChanges[].by` - * reports, `MetadataAuthoringSurface`), minus `'copilot'`: the HTTP doors - * refuse `?surface=copilot` `400` (only an in-process tool may state it), so - * this client cannot send it. Omit for any other caller. + * reports, `MetadataAuthoringSurface`), minus `'copilot'` and `'mcp'`: the + * HTTP doors refuse `?surface=copilot` and `?surface=mcp` `400` (only an + * in-process tool, or the MCP build door, may state them — objectstack#27176), + * so this client cannot send them. Omit for any other caller. */ - surface?: Exclude; + surface?: Exclude; } export interface MetadataGetOptions { diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 2ad024a7685..36053d3d63a 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -4681,6 +4681,10 @@ const ar = { detailLoading: 'جارٍ تحميل التفاصيل…', detailLoadFailed: 'تعذر تحميل تفاصيل التغيير:', detailNone: 'لم يتم اكتشاف اختلافات — المسودة مطابقة للنسخة المنشورة.', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: 'يتضمن تغييرات صاغها مساعد الذكاء الاصطناعي', + draftedByMcpClient: 'يتضمن تغييرات صاغها عميل MCP «{{client}}»', + draftedByMcp: 'يتضمن تغييرات صاغها عميل MCP', detailChangedKeys: 'تغييرات أخرى:', detailNewKeys: 'يتضمن:', generatedGroup: 'أنشأتها المنصة ({{n}})', diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 2b8341e7c60..34e1f764e96 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4312,6 +4312,10 @@ const de = { detailLoading: 'Details werden geladen…', detailLoadFailed: 'Änderungsdetails konnten nicht geladen werden:', detailNone: 'Keine Unterschiede erkannt — der Entwurf entspricht der veröffentlichten Version.', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: 'Enthält Änderungen, die der KI-Copilot entworfen hat', + draftedByMcpClient: 'Enthält Änderungen, die der MCP-Client „{{client}}“ entworfen hat', + draftedByMcp: 'Enthält Änderungen, die ein MCP-Client entworfen hat', detailChangedKeys: 'Ebenfalls geändert:', detailNewKeys: 'Enthält:', generatedGroup: 'Von der Plattform erzeugt ({{n}})', diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index adef92955e8..f035279413b 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3742,6 +3742,10 @@ const en = { detailLoading: 'Loading detail…', detailLoadFailed: 'Could not load change detail:', detailNone: 'No differences detected — the draft matches the published version.', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: 'Includes changes drafted by the AI copilot', + draftedByMcpClient: 'Includes changes drafted by the MCP client “{{client}}”', + draftedByMcp: 'Includes changes drafted by an MCP client', detailChangedKeys: 'Also changed:', detailNewKeys: 'Includes:', generatedGroup: 'Generated by the platform ({{n}})', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index d70bb257d7f..85254e5c841 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4405,6 +4405,10 @@ const es = { detailLoading: 'Cargando detalles…', detailLoadFailed: 'No se pudieron cargar los detalles del cambio:', detailNone: 'No se detectaron diferencias — el borrador coincide con la versión publicada.', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: 'Incluye cambios redactados por el copiloto de IA', + draftedByMcpClient: 'Incluye cambios redactados por el cliente MCP «{{client}}»', + draftedByMcp: 'Incluye cambios redactados por un cliente MCP', detailChangedKeys: 'También cambió:', detailNewKeys: 'Incluye:', generatedGroup: 'Generado por la plataforma ({{n}})', diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index b47970a0311..4debe2c4a7c 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4401,6 +4401,10 @@ const fr = { detailLoading: 'Chargement des détails…', detailLoadFailed: 'Impossible de charger les détails de la modification :', detailNone: 'Aucune différence détectée — le brouillon correspond à la version publiée.', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: 'Inclut des modifications rédigées par le copilote IA', + draftedByMcpClient: 'Inclut des modifications rédigées par le client MCP « {{client}} »', + draftedByMcp: 'Inclut des modifications rédigées par un client MCP', detailChangedKeys: 'Également modifié :', detailNewKeys: 'Inclut :', generatedGroup: 'Généré par la plateforme ({{n}})', diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 948a3145f21..cd5717ab343 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4314,6 +4314,10 @@ const ja = { detailLoading: '詳細を読み込み中…', detailLoadFailed: '変更の詳細を読み込めませんでした:', detailNone: '差分は検出されませんでした — ドラフトは公開版と一致しています。', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: 'AI コパイロットが下書きした変更を含みます', + draftedByMcpClient: 'MCP クライアント「{{client}}」が下書きした変更を含みます', + draftedByMcp: 'MCP クライアントが下書きした変更を含みます', detailChangedKeys: 'その他の変更:', detailNewKeys: '含まれるもの:', generatedGroup: 'プラットフォームが生成({{n}})', diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 2b7b27eef32..f5436ca6f24 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4311,6 +4311,10 @@ const ko = { detailLoading: '상세 정보 로드 중…', detailLoadFailed: '변경 상세 정보를 불러오지 못했습니다:', detailNone: '차이가 감지되지 않았습니다 — 초안이 게시된 버전과 일치합니다.', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: 'AI 코파일럿이 초안으로 작성한 변경 사항 포함', + draftedByMcpClient: 'MCP 클라이언트 “{{client}}”이(가) 초안으로 작성한 변경 사항 포함', + draftedByMcp: 'MCP 클라이언트가 초안으로 작성한 변경 사항 포함', detailChangedKeys: '기타 변경:', detailNewKeys: '포함:', generatedGroup: '플랫폼에서 생성됨 ({{n}})', diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index e550570edf1..c7d04e9e563 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4400,6 +4400,10 @@ const pt = { detailLoading: 'Carregando detalhes…', detailLoadFailed: 'Não foi possível carregar os detalhes da alteração:', detailNone: 'Nenhuma diferença detectada — o rascunho corresponde à versão publicada.', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: 'Inclui alterações rascunhadas pelo copiloto de IA', + draftedByMcpClient: 'Inclui alterações rascunhadas pelo cliente MCP “{{client}}”', + draftedByMcp: 'Inclui alterações rascunhadas por um cliente MCP', detailChangedKeys: 'Também alterado:', detailNewKeys: 'Inclui:', generatedGroup: 'Gerado pela plataforma ({{n}})', diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index ae88634a670..253d5e81002 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4507,6 +4507,10 @@ const ru = { detailLoading: 'Загрузка деталей…', detailLoadFailed: 'Не удалось загрузить детали изменения:', detailNone: 'Различий не обнаружено — черновик совпадает с опубликованной версией.', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: 'Включает изменения, подготовленные ИИ-копилотом', + draftedByMcpClient: 'Включает изменения, подготовленные MCP-клиентом «{{client}}»', + draftedByMcp: 'Включает изменения, подготовленные MCP-клиентом', detailChangedKeys: 'Также изменено:', detailNewKeys: 'Включает:', generatedGroup: 'Создано платформой ({{n}})', diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index fd66f2691cb..d2ab6406ce2 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -3230,6 +3230,10 @@ const zh = { detailLoading: '正在加载详情…', detailLoadFailed: '无法加载变更详情:', detailNone: '未检测到差异 —— 草稿与已发布版本一致。', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: '包含由 AI 副驾起草的更改', + draftedByMcpClient: '包含由 MCP 客户端「{{client}}」起草的更改', + draftedByMcp: '包含由 MCP 客户端起草的更改', detailChangedKeys: '其他变更:', detailNewKeys: '包含:', generatedGroup: '平台生成({{n}})', diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index c3588d23941..1f34f95299d 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3162,10 +3162,14 @@ const STUDIO_EN: Record = { // 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.source': 'Added as a draft by {agent} {when}', + 'engine.studio.draftMark.sourceJustNow': 'Added as a draft by {agent} just now', + 'engine.studio.draftMark.sourceItems': '{items}: {count} added as a draft by {agent} {when}', + 'engine.studio.draftMark.sourceItemsJustNow': '{items}: {count} added as a draft by {agent} just now', + // objectstack#27176 — who drafted it: the copilot, or an agent through the MCP build door. + 'engine.studio.draftMark.agent.copilot': 'the AI copilot', + 'engine.studio.draftMark.agent.mcp': 'an MCP client', + 'engine.studio.draftMark.agent.mcpClient': 'the MCP client "{client}"', 'engine.studio.draftMark.item': '"{label}"', 'engine.studio.draftMark.minutesAgo': '{n} min ago', 'engine.studio.draftMark.hoursAgo': '{n} h ago', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 2151e472bf6..fea9f266025 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2811,10 +2811,14 @@ const STUDIO_ZH: Record = { 'engine.studio.nav.visibleWhen': '可见条件', '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.source': '由 {agent}在 {when}作为草稿加入', + 'engine.studio.draftMark.sourceJustNow': '由 {agent}刚刚作为草稿加入', + 'engine.studio.draftMark.sourceItems': '{items} {count} 项由 {agent}在 {when}作为草稿加入', + 'engine.studio.draftMark.sourceItemsJustNow': '{items} {count} 项由 {agent}刚刚作为草稿加入', + // objectstack#27176 — 谁起草的:AI 副驾,或经 MCP 构建入口的智能体。 + 'engine.studio.draftMark.agent.copilot': 'AI 副驾', + 'engine.studio.draftMark.agent.mcp': 'MCP 客户端', + 'engine.studio.draftMark.agent.mcpClient': 'MCP 客户端「{client}」', 'engine.studio.draftMark.item': '「{label}」', 'engine.studio.draftMark.minutesAgo': '{n} 分钟前', 'engine.studio.draftMark.hoursAgo': '{n} 小时前', From 2b1028a55ae009cc51266f0696e29088d215d458 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:16:52 +0000 Subject: [PATCH 3/4] fix(spec): close MetadataAuthoringClientSchema and judge the save's client by it The writer's authoringClientOf refused an unknown member that the published schema stripped; the schema is now .strict() and the writer parses through it, so the contract and the enforcement give one verdict. Its describe no longer names itself. Claude-Session: https://claude.ai/code/session_01QUs3BthV9Hg8gmo8r35XcV Co-Authored-By: Claude --- content/docs/references/api/protocol.mdx | 2 +- .../metadata-protocol/src/draft-changes.ts | 21 ++++++++----------- .../src/api/meta-item-response-shapes.test.ts | 4 ++-- packages/spec/src/api/protocol.zod.ts | 6 ++++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index d550d159dc7..d2e85316487 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2404,7 +2404,7 @@ Installed package with runtime lifecycle state ## MetadataAuthoringClient -The OAuth client an MCP build-door write was made by (see `MetadataAuthoringClientSchema`). +The OAuth client an MCP build-door write was made by: the client a person consented to with `metadata:write`, as the MCP build door verified it from the request's token. Closed: a member other than `id` and `name` is refused, never stripped. ### Properties diff --git a/packages/metadata-protocol/src/draft-changes.ts b/packages/metadata-protocol/src/draft-changes.ts index 92ed3b6ac51..d9acd12f12a 100644 --- a/packages/metadata-protocol/src/draft-changes.ts +++ b/packages/metadata-protocol/src/draft-changes.ts @@ -29,7 +29,7 @@ */ import { canonicalize, orderedMapKeys } from '@objectstack/metadata-core'; -import type { MetadataAuthoringClient, MetadataAuthoringSurface } from '@objectstack/spec/api'; +import { MetadataAuthoringClientSchema, type MetadataAuthoringClient, type MetadataAuthoringSurface } from '@objectstack/spec/api'; /** One entry of `draftChanges`, as stored and as served. */ export interface DraftChangeEntry { @@ -56,19 +56,16 @@ function isPlainObject(value: unknown): value is Record { } /** - * `value` as a `MetadataAuthoringClient` (a non-empty `id`, an optional - * non-empty `name`, nothing else), copied member by member, or `undefined` - * when it is not one. The save door refuses a `client` this answers - * `undefined` for; the stored read treats such an entry as malformed. + * `value` as a `MetadataAuthoringClient`, or `undefined` when it is not one — + * judged by the spec's own `MetadataAuthoringClientSchema` (closed: a + * non-empty `id`, an optional non-empty `name`, nothing else), so the writer + * never holds a second, drifting verdict. The save door refuses a `client` + * this answers `undefined` for; the stored read treats such an entry as + * malformed. */ export function authoringClientOf(value: unknown): MetadataAuthoringClient | undefined { - if (!isPlainObject(value)) return undefined; - const { id, name, ...rest } = value; - if (Object.keys(rest).length > 0) return undefined; - if (typeof id !== 'string' || id === '') return undefined; - if (name === undefined) return { id }; - if (typeof name !== 'string' || name === '') return undefined; - return { id, name }; + const parsed = MetadataAuthoringClientSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; } /** diff --git a/packages/spec/src/api/meta-item-response-shapes.test.ts b/packages/spec/src/api/meta-item-response-shapes.test.ts index 302861e6c9c..c2a7d05bf61 100644 --- a/packages/spec/src/api/meta-item-response-shapes.test.ts +++ b/packages/spec/src/api/meta-item-response-shapes.test.ts @@ -356,8 +356,8 @@ describe('`draftChanges` — which surface last changed each key of a pending dr } }); - it('#27176 — refuses a client with no id, an empty id or an empty name, at the entry\'s `client`', () => { - for (const client of [{}, { id: '' }, { id: 'cli_1', name: '' }, 'cli_1']) { + it('#27176 — refuses a client with no id, an empty id, an empty name or an unknown member, at the entry\'s `client`', () => { + for (const client of [{}, { id: '' }, { id: 'cli_1', name: '' }, { id: 'cli_1', secret: 's' }, 'cli_1']) { expect(issueOf([{ ...ENTRIES[3], client }])?.path?.slice(0, 3)).toEqual(['draftChanges', 0, 'client']); } }); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index aab56f70055..2e07ced5def 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -521,8 +521,10 @@ export const MetadataAuthoringClientSchema = z.object({ 'The client\'s registered display name (its OAuth `client_name`) when it registered one, as ' + 'registered. Absent when it registered none; ⛔ never the id standing in for it.', ), -}).describe( - 'The OAuth client an MCP build-door write was made by (see `MetadataAuthoringClientSchema`).', +}).strict().describe( + 'The OAuth client an MCP build-door write was made by: the client a person consented to with ' + + '`metadata:write`, as the MCP build door verified it from the request\'s token. Closed: a member ' + + 'other than `id` and `name` is refused, never stripped.', ); export type MetadataAuthoringClient = z.input; From 532985f37f9aa4460526df86699de0a4f7cbde91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:37:45 +0000 Subject: [PATCH 4/4] test(i18n): count the de changes-drawer MCP-client line's quote pair Claude-Session: https://claude.ai/code/session_01QUs3BthV9Hg8gmo8r35XcV Co-Authored-By: Claude --- .../i18n/src/__tests__/de-quote-pairing-3876.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 3fe52c09cfa..745cfb1bf21 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -356,7 +356,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // („{{title}}“) — one interpolated span each, runtime data. // 97 once objectstack-ai/objectstack#26469 reworded // `chatbot.build.draftPosture` to name the chat's „Veröffentlichen“ button. - expect(okSpans, 'correctly paired spans').toBe(97); + // 98 once objectstack-ai/objectstack#27176 added + // `preview.changes.draftedByMcpClient`, the changes drawer's line that names + // the MCP client („{{client}}“) — one interpolated span, runtime data. + expect(okSpans, 'correctly paired spans').toBe(98); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -422,8 +425,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // after objectstack-ai/objectstack#26172 added `form.createdNamed` and // `.updatedNamed`, one matched pair each. 97 / 97 / 0 after // objectstack-ai/objectstack#26469 reworded `chatbot.build.draftPosture`, - // one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 97, close: 97, rdq: 0 }); + // one more matched pair. 98 / 98 / 0 after objectstack-ai/objectstack#27176 + // added `preview.changes.draftedByMcpClient`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 98, close: 98, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq);