Skip to content

Commit 8d79bbd

Browse files
os-zhuangclaude
andauthored
feat(spec): draft provenance records MCP build-door writes and the OAuth client that made them (#27220)
Fixes #27176 ## What changes A pending draft's per-key provenance (`draftChanges`, served on the strict `?state=draft` read) can now say that a key was written by an AI agent outside the console, through a deployment's MCP build door, and which OAuth client that agent was. **Spec (`@objectstack/spec/api`)** - `MetadataAuthoringSurfaceSchema` gains `mcp`. All the describes that list the set (`draftChanges[].by`, the save's `surface`, the docblocks) now name it. - New `MetadataAuthoringClientSchema` (`{ id, name? }`): the OAuth client the person consented to. `id` is the verified token's client id (opaque). `name` is its registered `client_name`, and it is never the id standing in for a name. - `draftChanges[]` gains an optional `client`, present only beside `by: 'mcp'`. - `SaveMetaItemRequestSchema` gains an optional `client`. It is stated only by the MCP build door, in-process, from the token it verified. No HTTP door reads it. - Pinned as isomorphic in `type-alias-convention.pin.test.ts` (798 → 799). Generated artifacts are regenerated: api-surface, export-origins, declaration-map, authorable-surface and json-schema manifest, reference docs, and the strictness ledger. **Writer (`@objectstack/metadata-protocol`)** - A draft save with `surface: 'mcp'` stamps `by: 'mcp'` and its `client` on every key path it changes (`draftChangesClient` on the repository `put`, beside `draftChangesBy`). `actor` stays the consenting person. - A `client` beside any other surface (or with none), or of the wrong shape, is refused `400 INVALID_REQUEST` before anything is read or written. `authoringClientOf` is the one shape check, shared by the save and the stored read. - `storedDraftChanges` reads `mcp` entries with or without a client. A stored client beside a non-`mcp` surface, or a malformed one, is malformed, like any other bad entry. **HTTP doors (`@objectstack/rest`, runtime dispatcher)** - `?surface=mcp` is refused `400 VALIDATION_ERROR` with its own sentence, as `?surface=copilot` is. Both doors share `metaSaveRequestOptions`. **Console** - `@object-ui/data-objectstack`: `MetadataClientSaveOptions.surface` excludes `mcp` as it excludes `copilot`. The type now has a `@ts-expect-error` pin for `mcp`. - Studio properties (`StudioDraftMark`): the draft chip and the 来源 line mark `mcp` writes as they mark the copilot's. The line names the client: 「由 MCP 客户端「Claude」在 2 分钟前作为草稿加入」 / "Added as a draft by the MCP client "Claude" 2 min ago". A client with no registered name reads as an unnamed MCP client, never by its id. - `copilotDraftMarkOf` → `agentDraftMarkOf`. Its marks carry `by` and `client`. - The source keys take an `{agent}` hole, with three new `engine.studio.draftMark.agent.*` rows (en + zh, the designer table's two languages). - Changes drawer (`DraftChangesPanel`): an entry's drill-in already sends the strict draft read. It now reads that read's `draftChanges` too, and lists which AI tools drafted part of the item: the copilot once, and each MCP client once. Copy is in all ten locale packs (`preview.changes.draftedBy*`). - Provenance stays off the `_drafts` row, as #24298 ruled. The drawer reads it from the per-item draft read it already sends. ## Why cloud#3302 opened the MCP build door: an agent with a `metadata:write` grant drafts metadata through the same authoring tools the copilot uses. Its writes were recorded as `api`, so a person reviewing the draft could not tell an agent's edit from any other caller's, or tell which agent it was. The cloud half (the door passing `surface: 'mcp'` and the verified client from its principal into the save) follows once the pin carries this change. ## Verification - `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated --fix`: the five stale artifacts were regenerated and re-checked green. `check:test-typecheck` is OK after updating `meta-item-response-shapes.test.ts` (the surface set and the client type are pinned; the client shape is refused at `draftChanges[n].client`). - spec: `meta-item-response-shapes.test.ts` and `type-alias-convention.pin.test.ts` pass (26). - metadata-protocol: `tsc --noEmit` is clean. `draft-changes.test.ts` and `protocol.packageless-save-address.test.ts` pass (103). The new cases cover the client stamp and keep rule, the unnamed client, an `mcp` save without a client, and refusal of a client beside another surface or of the wrong shape, with nothing written. - rest: `rest-server-meta-door-closed-sets.test.ts` passes (29), with `?surface=mcp` refused. runtime: `meta-save-preconditions-parity.test.ts` passes (23), with `?surface=mcp` refused on the dispatcher too. - ui: - `tsc --noEmit` is clean for `app-shell` and `data-objectstack`. - `StudioDraftMark.test.ts`, the new `DraftChangesPanel.agentSource-27176.test.tsx`, and `draftChanges-12725.test.ts` pass. - Every `DraftChangesPanel.*` suite and every copilot-mark suite (52 files) passes. - `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity` and `check:i18n-dead-keys` show nothing for the new keys. - `pnpm lint` on the touched files: 0 errors. Clause-②: yes (widening) Metadata-protocol: added ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3BthV9Hg8gmo8r35XcV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9dec054 commit 8d79bbd

43 files changed

Lines changed: 746 additions & 134 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/rest': patch
5+
'@object-ui/data-objectstack': patch
6+
'@object-ui/app-shell': patch
7+
'@object-ui/i18n': patch
8+
---
9+
10+
feat(spec): a draft's per-key provenance records writes made through the MCP build door, and the OAuth client that made them
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: added
14+
15+
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.
16+
17+
- **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.
18+
- **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.
19+
- **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`.
20+
- **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.
21+
22+
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.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 31 additions & 7 deletions
Large diffs are not rendered by default.

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1559 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1560 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ 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
2121
| Module | Pages | Schemas | Description |
2222
| :--- | ---: | ---: | :--- |
2323
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
24-
| [API Protocol](/docs/references/api) | 32 | 439 | REST contracts, endpoints, routing, realtime, batch, discovery. |
24+
| [API Protocol](/docs/references/api) | 32 | 440 | REST contracts, endpoints, routing, realtime, batch, discovery. |
2525
| [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
2626
| [Data Protocol](/docs/references/data) | 31 | 185 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
2727
| [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
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3636
| [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **202** | **1559** | 14 protocol modules |
37+
| **Total** | **202** | **1560** | 14 protocol modules |
3838

3939
---
4040

@@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations.
6363

6464
## API Protocol
6565

66-
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 439 schemas**
66+
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 440 schemas**
6767

6868
REST contracts, endpoints, routing, realtime, batch, discovery.
6969

@@ -91,7 +91,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
9191
| [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` |
9292
| [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` |
9393
| [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` |
94-
| [`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` |
94+
| [`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` |
9595
| [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` |
9696
| [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` |
9797
| [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `api/` | 448 |
22+
| `api/` | 449 |

‎packages/metadata-protocol/src/draft-changes.test.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111
import { describe, expect, it } from 'vitest';
1212
import { GetMetaItemResponseSchema } from '@objectstack/spec/api';
13-
import { draftChangedPaths, mergeDraftChanges, storedDraftChanges, type DraftChangeEntry } from './draft-changes.js';
13+
import { authoringClientOf, draftChangedPaths, mergeDraftChanges, storedDraftChanges, type DraftChangeEntry } from './draft-changes.js';
1414

1515
const AT = '2026-10-18T12:00:00.000Z';
1616
const AT2 = '2026-10-18T12:05:00.000Z';
@@ -105,6 +105,36 @@ describe('mergeDraftChanges — no two entries share a path; the longest prefix
105105
const entries = mergeDraftChanges([], [['label']], copilot);
106106
expect(mergeDraftChanges(entries, [['label']], studio)).toEqual([{ path: ['label'], ...studio }]);
107107
});
108+
109+
it('[#27176] an `mcp` stamp carries its client onto each path; a stamp with none adds no `client` key', () => {
110+
const mcp = { by: 'mcp' as const, actor: 'u_1', at: AT, client: { id: 'cli_1', name: 'Claude' } };
111+
expect(mergeDraftChanges([], [['label'], ['columns']], mcp)).toEqual([
112+
{ path: ['label'], ...mcp },
113+
{ path: ['columns'], ...mcp },
114+
]);
115+
const [bare] = mergeDraftChanges([], [['label']], { by: 'mcp', actor: null, at: AT });
116+
expect('client' in bare!).toBe(false);
117+
});
118+
});
119+
120+
describe('[#27176] authoringClientOf — the one shape a client takes', () => {
121+
it('copies a non-empty id and an optional non-empty name, member by member', () => {
122+
expect(authoringClientOf({ id: 'cli_1' })).toEqual({ id: 'cli_1' });
123+
expect(authoringClientOf({ id: 'cli_1', name: 'Claude' })).toEqual({ id: 'cli_1', name: 'Claude' });
124+
});
125+
126+
it.each([
127+
['not an object', 'cli_1'],
128+
['null', null],
129+
['an array', [{ id: 'cli_1' }]],
130+
['an empty id', { id: '' }],
131+
['no id', { name: 'Claude' }],
132+
['an empty name', { id: 'cli_1', name: '' }],
133+
['a non-string name', { id: 'cli_1', name: 7 }],
134+
['an extra member', { id: 'cli_1', secret: 's' }],
135+
])('%s is not a client', (_label, value) => {
136+
expect(authoringClientOf(value)).toBeUndefined();
137+
});
108138
});
109139

110140
describe('storedDraftChanges — the stored form, never []', () => {
@@ -115,6 +145,12 @@ describe('storedDraftChanges — the stored form, never []', () => {
115145
expect(storedDraftChanges([entry])).toEqual([entry]);
116146
});
117147

148+
it('[#27176] reads an `mcp` entry with its client, and one without', () => {
149+
const mcp: DraftChangeEntry = { path: ['label'], by: 'mcp', actor: 'u_1', at: AT, client: { id: 'cli_1', name: 'Claude' } };
150+
const bare: DraftChangeEntry = { path: ['columns'], by: 'mcp', actor: 'u_1', at: AT };
151+
expect(storedDraftChanges(JSON.stringify([mcp, bare]))).toEqual([mcp, bare]);
152+
});
153+
118154
it.each([
119155
['NULL (a row saved before the writer)', null],
120156
['an absent column', undefined],
@@ -124,6 +160,8 @@ describe('storedDraftChanges — the stored form, never []', () => {
124160
['an unknown surface', JSON.stringify([{ ...entry, by: 'robot' }])],
125161
['an empty segment', JSON.stringify([{ ...entry, path: [''] }])],
126162
['a dotted-string path', JSON.stringify([{ ...entry, path: 'a.b' }])],
163+
['a client beside a surface other than mcp', JSON.stringify([{ ...entry, client: { id: 'cli_1' } }])],
164+
['a malformed client', JSON.stringify([{ ...entry, by: 'mcp', client: { id: '' } }])],
127165
])('%s reads as not recorded (undefined), never []', (_label, raw) => {
128166
expect(storedDraftChanges(raw)).toBeUndefined();
129167
});

0 commit comments

Comments
 (0)