diff --git a/.changeset/24395-draft-changes-surface.md b/.changeset/24395-draft-changes-surface.md new file mode 100644 index 00000000000..8fae6d4d0b0 --- /dev/null +++ b/.changeset/24395-draft-changes-surface.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): a metadata save declares its authoring surface — `SaveMetaItemRequestSchema.surface`, in one exported closed set shared with `draftChanges[].by` + +Clause-②: yes (widening) + +- **One closed set, one name.** `MetadataAuthoringSurfaceSchema` (`copilot | studio | api`) is exported from `@objectstack/spec/api`, with its type `MetadataAuthoringSurface`. `GetMetaItemResponseSchema.draftChanges[].by` now reads it instead of an inline enum: the accepted values are unchanged. +- **The save states its surface.** `SaveMetaItemRequestSchema` gains an optional `surface` from that set. A draft save stamps it as `by` on each key path it changes; an absent `surface` stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` only: `copilot` is stated by an in-process AI tool, never over HTTP. +- **The writer exists.** The `draftChanges` describe no longer says no server writes it: it states the writer's rules (the first draft save is compared with the stored published row, an item with no stored published row is recorded as `[]`, and a `migrate-stored` rewrite records nothing). `18.0.0-next.4`'s note said no server writes `draftChanges` yet; from this release the draft save writes it. diff --git a/.changeset/24395-draft-changes-writer.md b/.changeset/24395-draft-changes-writer.md new file mode 100644 index 00000000000..2eba916839e --- /dev/null +++ b/.changeset/24395-draft-changes-writer.md @@ -0,0 +1,16 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/metadata-core": minor +"@objectstack/platform-objects": minor +--- + +feat(metadata-protocol): the draft save records `draftChanges` — which surface, actor and instant last changed each key path of a pending draft — and the strict draft read serves it + +Clause-②: yes (widening) + +- **Recorded by the draft save.** `saveMetaItem({ mode: 'draft', surface })` compares the body it writes with the body it replaces, inside the write's own transaction: the draft row, else (the draft's first save) the active row its publish would replace, else nothing, which records `[]`, the whole body. A path is the object keys from the body root to the deepest changed key; an array is one leaf, with no index. Each changed path is stamped `{ by: surface ?? 'api', actor, at }`, where `actor` is the value the save's history event carries (or `null`) and `at` is the write's instant as `toISOString()`. +- **Kept until publish or discard.** No two entries share a path: a save replaces every entry at or under each path it changes, so the longest prefix answers for a key. A save that changes no key path leaves the entries as they were. Publish and discard delete the draft row, and the entries with it, so the next draft starts clean. A `migrate-stored` rewrite and an active save record nothing. +- **Served on the strict draft read only.** `getMetaItem({ state: 'draft' })` answering a stored draft row carries `draftChanges` beside `item`. It is absent on the active read, the cached branch, the `?preview=draft` read, and a draft row whose saves recorded none (one saved before this release) — never `[]`. +- **Stored on the row.** `sys_metadata` gains a nullable `draft_changes` column (`@objectstack/metadata-core`'s `SysMetadataObject`), never inside the stored body. Schema sync adds it to an existing table on boot with no manual step (measured on SQLite and the memory driver). +- **Translated labels.** `@objectstack/platform-objects`' translation bundles carry the new column's label and help text in every shipped locale. +- **An unknown `surface` is refused.** In-process, a value outside `copilot | studio | api` is refused `400 INVALID_REQUEST` before anything is written. diff --git a/.changeset/24395-http-save-surface.md b/.changeset/24395-http-save-surface.md new file mode 100644 index 00000000000..22747fcea07 --- /dev/null +++ b/.changeset/24395-http-save-surface.md @@ -0,0 +1,12 @@ +--- +"@objectstack/rest": minor +"@objectstack/runtime": minor +--- + +feat(rest,runtime): `PUT /meta/:type/:name?surface=studio|api` declares the save's authoring surface on both HTTP save doors; `?surface=copilot` is refused + +Clause-②: yes (widening) + +- **The spelling.** `?surface=` joins the save door's closed query set on `RestServer` and on the runtime dispatcher (`META_ITEM_SAVE_PARAMS`), read once through `metaSaveRequestOptions` beside `?mode=`. A draft save stamps it on each key path it changes (`draftChanges[].by` on the strict draft read); without it the save is recorded as `api`. `?surface=studio` is the spelling for a person's save in Studio. +- **The refusals.** `?surface=copilot` is `400 VALIDATION_ERROR`: it marks a write as the AI copilot's, which only an in-process tool calling `saveMetaItem` may state. Any other value, a case variant included, is the same `400`, never read as `api`; a repeated `?surface=` is refused by the arity rule. Each refusal writes nothing. +- **Why a query parameter.** It is what the save is asked to do, as `?mode=` and `?force=` are, beside the HTTP preconditions the headers carry; and it needs no CORS allow-list entry, which a custom request header would. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 8b0e3e5bded..9feb760a153 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 136 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 137 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, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, 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, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, 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, 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, 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'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -1457,7 +1457,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. No server writes the key yet: until the engine's draft save records it, every draft read is one whose saves recorded none, and the key is absent. The contract a writer must meet: it compares each draft save with the body that save replaced (the previous draft, or the published body for the draft's first save); it keeps the entries from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and it keeps no two entries on one path, replacing every entry at or under a path a save 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' \| '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". | ### Nested Shape: `GetMetaItemResponse.sortability` @@ -1491,7 +1491,7 @@ 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. 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' \| '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`. | | **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. | | **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. | @@ -2393,6 +2393,19 @@ Installed package with runtime lifecycle state | **readCount** | `number` | ✅ | Number of notifications marked as read | +--- + +## 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`. + +### Allowed Values + +* `copilot` +* `studio` +* `api` + + --- ## MetadataConflictError @@ -2769,6 +2782,7 @@ Installed package with runtime lifecycle state | **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. | --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index c4b60801099..178df51d814 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 — 1540 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1541 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 | 437 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 71 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 176 | 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) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1540** | 14 protocol modules | +| **Total** | **197** | **1541** | 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, 437 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 438 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`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `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`, `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` | | [`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/packages/metadata-core/src/objects/sys-metadata.object.ts b/packages/metadata-core/src/objects/sys-metadata.object.ts index 81f9d8d8bc0..8fa900fdf5f 100644 --- a/packages/metadata-core/src/objects/sys-metadata.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata.object.ts @@ -121,6 +121,24 @@ export const SysMetadataObject = ObjectSchema.create({ description: 'JSON-serialized metadata payload', }), + /** + * [#24395] A draft row's per-key provenance — the entries the strict draft + * read serves as `draftChanges` (`GetMetaItemResponseSchema` in + * `@objectstack/spec/api`): which authoring surface, actor and instant last + * changed each key path of the draft body. JSON-serialized array, written + * by the overlay repository's draft save, never authored. + * + * On the row, ⛔ never inside `metadata`: publishing and discarding a draft + * both delete its row, so the entries go with it. NULL on every active row + * and on a draft row whose saves recorded none. + */ + draft_changes: Field.textarea({ + label: 'Draft Changes', + required: false, + description: + 'JSON-serialized per-key provenance of a pending draft: which surface (copilot, studio, api), actor and time last changed each key path. Null on active rows.', + }), + /** Parent metadata name for extension/override */ extends: Field.text({ label: 'Extends', diff --git a/packages/metadata-protocol/src/draft-changes.test.ts b/packages/metadata-protocol/src/draft-changes.test.ts new file mode 100644 index 00000000000..55c385cf99b --- /dev/null +++ b/packages/metadata-protocol/src/draft-changes.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24395] The draft save's per-key provenance, as three pure functions — see + * `draft-changes.ts`. The save and read doors that call them are pinned + * through the real protocol in `protocol.packageless-save-address.test.ts`'s + * `[#24395]` block; these rows pin the rules themselves: the path grammar + * (object keys only, an array is one leaf, `[]` is the whole body), the keep + * rule (replace at or under), and the stored form (never `[]`). + */ +import { describe, expect, it } from 'vitest'; +import { GetMetaItemResponseSchema } from '@objectstack/spec/api'; +import { 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'; + +describe('draftChangedPaths — which key paths a draft save changed', () => { + it('[] when nothing was replaced: the save created the item as a draft', () => { + expect(draftChangedPaths(undefined, { name: 'v', label: 'L' }, 'view')).toEqual([[]]); + }); + + it('stops at the deepest key whose value differs, added or removed', () => { + const before = { name: 'o', fields: { a: { type: 'text', label: 'A' } }, description: 'd' }; + const after = { name: 'o', fields: { a: { type: 'text', label: 'A2' }, b: { type: 'number' } } }; + expect(draftChangedPaths(before, after, 'object')).toEqual([ + ['fields', 'a', 'label'], + ['fields', 'b'], + ['description'], + ]); + }); + + it('an array is one leaf: its path ends at its key and carries no index', () => { + const before = { columns: [{ field: 'name' }], kanban: { columns: ['a'] } }; + const after = { columns: [{ field: 'name' }, { field: 'email' }], kanban: { columns: ['a', 'b'] } }; + expect(draftChangedPaths(before, after, 'view')).toEqual([['columns'], ['kanban', 'columns']]); + }); + + it('a segment is the key as stored, dots included, never one dotted string', () => { + const before = { fields: { 'crm_lead.email': { readable: true } } }; + const after = { fields: { 'crm_lead.email': { readable: false } } }; + expect(draftChangedPaths(before, after, 'permission')).toEqual([['fields', 'crm_lead.email', 'readable']]); + }); + + it('an unchanged body, or a key order change in a map whose order is not content, changes nothing', () => { + expect(draftChangedPaths({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }, 'view')).toEqual([]); + expect(draftChangedPaths({ a: 1, b: 2 }, { b: 2, a: 1 }, 'view')).toEqual([]); + }); + + it("a pure reorder of a declared ordered map (an object's `fields`) is a change at the map", () => { + const before = { fields: { a: { type: 'text' }, b: { type: 'text' } } }; + const after = { fields: { b: { type: 'text' }, a: { type: 'text' } } }; + expect(draftChangedPaths(before, after, 'object')).toEqual([['fields']]); + // The same reorder in a type with no ordered map is no change, as its hash says. + expect(draftChangedPaths(before, after, 'view')).toEqual([]); + }); + + it('a change under an empty key is reported at its parent: an empty segment is no path', () => { + expect(draftChangedPaths({ m: { '': 1, x: 1 } }, { m: { '': 2, x: 2 } }, 'view')).toEqual([['m']]); + }); + + it('every path it answers is one the spec admits', () => { + const paths = draftChangedPaths({ a: { b: 1 } }, { a: { b: 2 }, c: [1] }, 'view'); + const parsed = GetMetaItemResponseSchema.safeParse({ + type: 'view', name: 'v', item: {}, + draftChanges: paths.map((path) => ({ path, by: 'studio', actor: null, at: AT })), + }); + expect(parsed.success).toBe(true); + }); +}); + +describe('mergeDraftChanges — no two entries share a path; the longest prefix answers', () => { + const copilot = { by: 'copilot' as const, actor: 'u_1', at: AT }; + const studio = { by: 'studio' as const, actor: 'u_1', at: AT2 }; + + it('a save replaces every entry at or under each path it changes', () => { + const entries = mergeDraftChanges([], [['fields', 'a', 'label'], ['fields', 'b'], ['label']], copilot); + const next = mergeDraftChanges(entries, [['fields']], studio); + expect(next).toEqual([ + { path: ['label'], ...copilot }, + { path: ['fields'], ...studio }, + ]); + }); + + it('an entry above a changed path is kept: a key under it is answered by the deeper entry', () => { + const entries = mergeDraftChanges([], [['kanban']], copilot); + const next = mergeDraftChanges(entries, [['kanban', 'columns']], studio); + expect(next).toEqual([ + { path: ['kanban'], ...copilot }, + { path: ['kanban', 'columns'], ...studio }, + ]); + }); + + it('a sibling path is not under a changed path: a shared first segment is not a prefix', () => { + const entries = mergeDraftChanges([], [['fields', 'ab']], copilot); + expect(mergeDraftChanges(entries, [['fields', 'a']], studio)).toHaveLength(2); + }); + + it('the whole body ([]) replaces every entry', () => { + const entries = mergeDraftChanges([], [['a'], ['b', 'c']], copilot); + expect(mergeDraftChanges(entries, [[]], studio)).toEqual([{ path: [], ...studio }]); + }); + + it('re-saving the same path keeps one entry, restamped', () => { + const entries = mergeDraftChanges([], [['label']], copilot); + expect(mergeDraftChanges(entries, [['label']], studio)).toEqual([{ path: ['label'], ...studio }]); + }); +}); + +describe('storedDraftChanges — the stored form, never []', () => { + const entry: DraftChangeEntry = { path: ['label'], by: 'copilot', actor: null, at: AT }; + + it('reads the serialized column and an already-parsed value alike', () => { + expect(storedDraftChanges(JSON.stringify([entry]))).toEqual([entry]); + expect(storedDraftChanges([entry])).toEqual([entry]); + }); + + it.each([ + ['NULL (a row saved before the writer)', null], + ['an absent column', undefined], + ['an empty string', ''], + ['an empty list', '[]'], + ['not JSON', '{'], + ['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' }])], + ])('%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 new file mode 100644 index 00000000000..119e99b9e12 --- /dev/null +++ b/packages/metadata-protocol/src/draft-changes.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24395] The writer of `GetMetaItemResponseSchema.draftChanges` — a pending + * draft's per-key provenance — as three pure functions the overlay repository + * calls inside its draft `put` and the strict draft read calls to serve it. + * The contract they meet is the key's describe in `@objectstack/spec/api` + * (`protocol.zod.ts`); this header restates none of it beyond what the code + * below decides. + * + * - {@link draftChangedPaths} — which key paths a draft save changed, from the + * body it replaced to the body it writes. A path stops at the deepest key + * whose value differs; an array, or any non-object value, is one leaf + * compared whole. `[[]]` when nothing was replaced (the save created the + * draft of an item with no stored published body). + * - {@link mergeDraftChanges} — the keep rule: every entry at or under a path + * the save changed is replaced by one entry at that path, so no two entries + * share a path and the longest prefix of a key's path answers for it. + * - {@link storedDraftChanges} — the entries a draft row stores + * (`sys_metadata.draft_changes`), `undefined` when it stores none, so the + * read never answers `[]` for a row whose saves recorded nothing. + * + * 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'; + +/** One entry of `draftChanges`, as stored and as served. */ +export interface DraftChangeEntry { + readonly path: readonly string[]; + readonly by: MetadataAuthoringSurface; + readonly actor: string | null; + readonly at: string; +} + +/** The write a {@link mergeDraftChanges} stamps on each path it changed. */ +export interface DraftChangeStamp { + readonly by: MetadataAuthoringSurface; + readonly actor: string | null; + readonly at: string; +} + +const SURFACES: ReadonlySet = new Set(['copilot', 'studio', 'api']); + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * 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 + * none; `type` names the metadata type, whose declared ordered maps + * (`orderedMapKeys`, an object's `fields`) make a pure reorder a change, as + * the hash does. + * + * A key whose segment would be empty (`""`) cannot be a path segment + * (`z.string().min(1)`), so a change under it is reported at its parent. + */ +export function draftChangedPaths(before: unknown, after: unknown, type?: string): string[][] { + if (before === undefined) return [[]]; + const out: string[][] = []; + const ordered = new Set(orderedMapKeys(type)); + const walk = (a: unknown, b: unknown, path: string[]): void => { + if (!isPlainObject(a) || !isPlainObject(b)) { + if (canonicalize(a) !== canonicalize(b)) out.push(path); + return; + } + const found = out.length; + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of keys) { + if (key === '') { + if (canonicalize(a[key]) !== canonicalize(b[key])) { + out.splice(found); + out.push(path); + return; + } + continue; + } + const inA = Object.prototype.hasOwnProperty.call(a, key) && a[key] !== undefined; + const inB = Object.prototype.hasOwnProperty.call(b, key) && b[key] !== undefined; + if (inA !== inB) out.push([...path, key]); + else if (inA) walk(a[key], b[key], [...path, key]); + } + // A declared ordered map whose members are unchanged but reordered: the + // order is content, and the map is the deepest key that holds it. + if (out.length === found && path.length === 1 && ordered.has(path[0]!) + && Object.keys(a).join('\u0000') !== Object.keys(b).join('\u0000')) { + out.push(path); + } + }; + walk(before, after, []); + return out; +} + +function isAtOrUnder(path: readonly string[], prefix: readonly string[]): boolean { + if (path.length < prefix.length) return false; + return prefix.every((segment, i) => path[i] === segment); +} + +/** + * The keep rule: `entries` (the draft row's, `[]` for none) after a save that + * changed `paths`, stamped `stamp`. Every entry at or under a changed path is + * dropped and one entry at that path takes its place; the rest are kept in + * their order, the new ones follow in `paths`' order. + */ +export function mergeDraftChanges( + entries: readonly DraftChangeEntry[], + paths: readonly (readonly string[])[], + 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 }))]; +} + +/** + * The entries a draft row stores, or `undefined` when it stores none — a row + * saved before the writer, a column a store does not carry, or a value that is + * not a list of well-formed entries. ⛔ Never `[]`: an empty list would read + * as "nothing changed" for exactly the rows nothing was recorded for. + */ +export function storedDraftChanges(raw: unknown): DraftChangeEntry[] | undefined { + let value = raw; + if (typeof value === 'string') { + if (value === '') return undefined; + try { value = JSON.parse(value); } catch { return undefined; } + } + if (!Array.isArray(value) || value.length === 0) return undefined; + const entries: DraftChangeEntry[] = []; + for (const e of value as unknown[]) { + if (!isPlainObject(e)) return undefined; + const { path, by, actor, at } = e; + if (!Array.isArray(path) || !path.every((s) => typeof s === 'string' && s !== '')) return 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 }); + } + 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 de5fc27b010..da28eec00ca 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -64,6 +64,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, isCodeArtifactBody } from '@objectstack/metadata-core'; import { applyProtection } from '@objectstack/spec/shared'; +import { GetMetaItemResponseSchema } from '@objectstack/spec/api'; import { resolveThrownHttpError } from '@objectstack/types'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; @@ -1366,3 +1367,122 @@ describe('[#23715] the strict draft read naming no package serves its content fr expect(read.version).toBeNull(); }); }); + +/** + * [#24395] The draft save records `draftChanges` and the strict draft read + * serves it — through the real save, read, publish and discard doors over this + * file's row-keeping engine (the write path is the same `resolveWriteHead` + * transaction the package-address rows above pin). The rules themselves (path + * grammar, keep rule, stored form) are `draft-changes.test.ts`'s. + */ +describe('[#24395] the draft save records per-key provenance, the strict draft read serves it', () => { + const view = (name: string, extra: Record = {}) => ({ ...viewBody(name, 'Base'), ...extra }); + const TWO_COLUMNS = [{ field: 'name', label: 'Name' }, { field: 'title', label: 'Title' }]; + const readDraft = (protocol: any, name: string) => protocol.getMetaItem({ type: 'view', name, state: 'draft' }); + const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + + 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) { + const name = `v_${surface ?? 'none'}`; + await protocol.saveMetaItem({ + type: 'view', name, item: view(name), mode: 'draft', actor: 'u_1', ...(surface ? { surface } : {}), + }); + const read = await readDraft(protocol, name); + expect(read.draftChanges).toEqual([{ path: [], by: surface ?? 'api', actor: 'u_1', at: expect.stringMatching(ISO_Z) }]); + expect(GetMetaItemResponseSchema.safeParse(read).success).toBe(true); + } + // No actor recorded → `null`, never a sentinel. + await protocol.saveMetaItem({ type: 'view', name: 'v_anon', item: view('v_anon'), mode: 'draft' }); + expect((await readDraft(protocol, 'v_anon')).draftChanges[0].actor).toBeNull(); + }); + + it('the first save is compared with the active row; the second replaces at or under what it changes', async () => { + const { protocol } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_b', item: view('v_b', { description: 'pub' }) }); + await protocol.saveMetaItem({ + type: 'view', name: 'v_b', mode: 'draft', surface: 'copilot', actor: 'u_1', + item: view('v_b', { description: 'pub', label: 'AI', columns: TWO_COLUMNS }), + }); + const first = (await readDraft(protocol, 'v_b')).draftChanges; + expect(first.map((e: any) => [e.path, e.by])).toEqual([[['label'], 'copilot'], [['columns'], 'copilot']]); + await protocol.saveMetaItem({ + type: 'view', name: 'v_b', mode: 'draft', surface: 'studio', actor: 'u_1', + item: view('v_b', { description: 'pub', label: 'Hand', columns: TWO_COLUMNS }), + }); + const second = (await readDraft(protocol, 'v_b')).draftChanges; + // `columns` keeps the copilot's entry; `label` is restamped once, never twice. + expect(second.map((e: any) => [e.path, e.by])).toEqual([[['columns'], 'copilot'], [['label'], 'studio']]); + expect(second[0]).toEqual(first[1]); + }); + + it('a save that changes no key path leaves the entries as they were', async () => { + const { protocol, rows } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_same', item: view('v_same'), mode: 'draft', surface: 'copilot' }); + const stored = rows('view', 'v_same', 'draft')[0].draft_changes; + await protocol.saveMetaItem({ type: 'view', name: 'v_same', item: view('v_same'), mode: 'draft', surface: 'studio' }); + expect(rows('view', 'v_same', 'draft')[0].draft_changes).toBe(stored); + }); + + it('ABSENT on the active read, the ?preview=draft read, and a draft row saved before the writer', async () => { + const { protocol, seedRow } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_abs', item: view('v_abs') }); + await protocol.saveMetaItem({ type: 'view', name: 'v_abs', item: view('v_abs', { label: 'D' }), mode: 'draft', surface: 'studio' }); + expect((await readDraft(protocol, 'v_abs')).draftChanges).toHaveLength(1); + expect('draftChanges' in (await protocol.getMetaItem({ type: 'view', name: 'v_abs' }))).toBe(false); + expect('draftChanges' in (await protocol.getMetaItem({ type: 'view', name: 'v_abs', previewDrafts: true }))).toBe(false); + seedRow({ type: 'view', name: 'v_legacy', state: 'draft', metadata: JSON.stringify(view('v_legacy')), checksum: null }); + const legacy = await readDraft(protocol, 'v_legacy'); + expect(legacy.item.name).toBe('v_legacy'); + expect('draftChanges' in legacy).toBe(false); + }); + + it('publish and discard leave no entry behind: the next draft starts from the published body', async () => { + const { protocol, rows } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_p', item: view('v_p') }); + await protocol.saveMetaItem({ type: 'view', name: 'v_p', item: view('v_p', { label: 'AI' }), mode: 'draft', surface: 'copilot' }); + await protocol.publishMetaItem({ type: 'view', name: 'v_p' }); + expect(rows('view', 'v_p', 'draft')).toEqual([]); + expect(rows('view', 'v_p', 'active').map((r) => r.draft_changes ?? null)).toEqual([null]); + await protocol.saveMetaItem({ type: 'view', name: 'v_p', item: view('v_p', { label: 'AI', description: 'd' }), mode: 'draft', surface: 'studio' }); + expect((await readDraft(protocol, 'v_p')).draftChanges.map((e: any) => [e.path, e.by])).toEqual([[['description'], 'studio']]); + + expect((await protocol.deleteMetaItem({ type: 'view', name: 'v_p', state: 'draft' })).reset).toBe(true); + expect(rows('view', 'v_p', 'draft')).toEqual([]); + await protocol.saveMetaItem({ type: 'view', name: 'v_p', item: view('v_p', { label: 'AI', columns: TWO_COLUMNS }), mode: 'draft' }); + expect((await readDraft(protocol, 'v_p')).draftChanges.map((e: any) => [e.path, e.by])).toEqual([[['columns'], 'api']]); + }); + + it('a draft whose body equals the published one records nothing, and the read answers it ABSENT, never []', async () => { + const { protocol } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_eq', item: view('v_eq') }); + await protocol.saveMetaItem({ type: 'view', name: 'v_eq', item: view('v_eq'), mode: 'draft', surface: 'copilot' }); + const read = await readDraft(protocol, 'v_eq'); + expect(read.item.name).toBe('v_eq'); + expect('draftChanges' in read).toBe(false); + }); + + it('a `migrate-stored` rewrite of a draft row records no path: it is not authoring', async () => { + const { protocol, rows } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_m', item: view('v_m'), mode: 'draft', surface: 'copilot' }); + const stored = rows('view', 'v_m', 'draft')[0].draft_changes; + await protocol.saveMetaItem({ type: 'view', name: 'v_m', item: view('v_m', { label: 'Rewritten' }), mode: 'draft', source: 'migrate-stored' }); + expect(JSON.parse(rows('view', 'v_m', 'draft')[0].metadata).label).toBe('Rewritten'); + expect(rows('view', 'v_m', 'draft')[0].draft_changes).toBe(stored); + }); + + it('an active save records nothing', async () => { + const { protocol, rows } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_a', item: view('v_a'), surface: 'studio' }); + expect(rows('view', 'v_a', 'active')[0].draft_changes ?? null).toBeNull(); + }); + + it('a surface outside the closed set is refused 400 INVALID_REQUEST before anything is written', async () => { + const { protocol, metadataWrites } = makeProtocol(); + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'view', name: 'v_bad', item: view('v_bad'), mode: 'draft', surface: 'robot', + }))); + expect(envelope).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 6abd847e4df..f2af251fb59 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -158,6 +158,10 @@ import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribable // [#23183] The app item read's package-id key — the one spelling of the arm // `GetMetaItemRequestSchema.name` declares ({@link resolveAppPackageIdKey}). 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 { parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf, AggregationFunction, DateGranularity, resolveSearchFieldResolution, @@ -11477,6 +11481,9 @@ export class ObjectStackProtocolImplementation implements // [#21899] Whether a stored row was found — the fact the origin-gated // repair's `deletable` reads ({@link servedLockState}). let storedRowServed = false; + // [#24395] The served draft row's per-key provenance — read off the + // row this read serves, so it can never describe another body. + let draftChanges: ReturnType; try { let record = (await this.findServedOverlayRow({ type: request.type, @@ -11512,6 +11519,7 @@ export class ObjectStackProtocolImplementation implements if (recPkg && item && typeof item === 'object' && (item as any)._packageId === undefined) { (item as any)._packageId = recPkg; } + if (readState === 'draft') draftChanges = storedDraftChanges((record as { draft_changes?: unknown }).draft_changes); } } catch (error) { // [#5532] THE site this issue was raised on. Falling through to the @@ -11540,6 +11548,10 @@ export class ObjectStackProtocolImplementation implements // [#22877] The member's own row's, as a save of that member's // body addresses it. version: await this.readVersionToken({ ...request, member }, 'draft'), + // [#24395] `GetMetaItemResponseSchema.draftChanges`: present + // only when the draft row's saves recorded at least one entry + // — ⛔ never `[]` for a row that recorded none. + ...(draftChanges ? { draftChanges } : {}), }; } @@ -22688,7 +22700,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 }) { + 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 }) { // [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}. @@ -22737,6 +22749,21 @@ export class ObjectStackProtocolImplementation implements err.status = 400; throw err; } + // [#24395] The authoring surface the draft save stamps on every key + // path it changes: a value outside the closed set is refused here, + // 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. + 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).`, + ); + err.code = 'INVALID_REQUEST'; + err.status = 400; + throw err; + } // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); // [commit 311433f6b] The item-name grammar verdict — static, request-only, so it @@ -24002,6 +24029,13 @@ export class ObjectStackProtocolImplementation implements intent, state: mode === 'draft' ? 'draft' : 'active', ...(request.packageId !== undefined ? { packageId: request.packageId } : {}), + // [#24395] An authoring draft save records which surface + // changed which key path (`api` when it names none). A + // 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' } + : {}), }); // Persistence succeeded — NOW it's safe to mutate the // in-memory registry. If put() had thrown, the registry diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 5630b9de7b4..13d451ba343 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -93,6 +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'; +// [#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'; /** * Canonicalise a driver-materialised timestamp into the ISO-8601 string the @@ -931,6 +935,53 @@ export class SysMetadataRepository implements MetadataRepository { return { row: row ?? null, targetPackageId, ...(ambiguousPackages ? { ambiguousPackages } : {}) }; } + /** + * [#24395] The draft row's `draft_changes` after a draft {@link put} of + * `body` over `existing` (the draft row it updates, `null` for a create): + * the serialized entries to write, or `undefined` when they do not change + * (the save changed no key path), so the column is left as it was. + * + * The body the save replaced is the draft row's; for the draft's first save + * it is the ACTIVE row its publish would replace ({@link promoteDraft} + * writes the draft's binding): that package's row, else the package-less one + * a read of that package is served (ADR-0048 prefer-local). With neither the + * save created the item as a draft, recorded as `[]`, the whole body. Read + * inside the caller's transaction, so the body compared is the one replaced. + */ + private async recordDraftChanges( + ref: Pick, + body: Record, + existing: any | null, + args: { + ctx: Record; + member: string | null | undefined; + targetPackageId: string | null; + stamp: { by: MetadataAuthoringSurface; actor: string | null; at: string }; + }, + ): Promise { + let replaced: any | null = existing; + if (!replaced) { + const context = { ...args.ctx, isSystem: true }; + replaced = await this.findEnvironmentRow({ + where: this.whereFor(ref, 'active', args.targetPackageId, args.member), + context, + }); + if (!replaced && args.targetPackageId !== null) { + replaced = await this.findEnvironmentRow({ + where: this.whereFor(ref, 'active', null, args.member), + context, + }); + } + } + const before = replaced + ? (typeof replaced.metadata === 'string' ? JSON.parse(replaced.metadata) : replaced.metadata ?? {}) + : undefined; + const paths = draftChangedPaths(before, body, ref.type); + if (paths.length === 0) return undefined; + const kept = existing ? storedDraftChanges(existing.draft_changes) ?? [] : []; + return JSON.stringify(mergeDraftChanges(kept, paths, args.stamp)); + } + /** * Resolve a historical version by content hash (ADR-0009). * @@ -978,7 +1029,19 @@ export class SysMetadataRepository implements MetadataRepository { async put( ref: MetaRef, spec: unknown, - opts: PutOptions & { state?: OverlayState; opType?: ExtendedOperation }, + opts: PutOptions & { + state?: OverlayState; + opType?: ExtendedOperation; + /** + * [#24395] The authoring surface a `draft` put records as `by` on every + * key path it changes (`sys_metadata.draft_changes`, served as the + * strict draft read's `draftChanges`). A LOCAL extension, as `state` is. + * Omitted → the put records nothing and leaves the row's entries as + * they are, which is what a write that is not authoring (the + * `migrate-stored` rewrite) states. Ignored on an `active` put. + */ + draftChangesBy?: MetadataAuthoringSurface; + }, ): Promise { this.assertOpen(); // [#7682] The base the caller NAMED is part of the authorization question, @@ -1084,6 +1147,17 @@ export class SysMetadataRepository implements MetadataRepository { } else { parentRowData.package_id = targetPackageId; } + // [#24395] The draft's per-key provenance, recorded in this transaction + // against the body this write replaces: the draft row's, else (the + // draft's first save) the active row its publish would replace, else + // nothing (`[]`, the whole body). Only a column that changes is written. + 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 }, + }); + if (draftChanges !== undefined) parentRowData.draft_changes = draftChanges; + } if (existing) { const existingId = (existing as { id?: string }).id; if (existingId === undefined) { diff --git a/packages/objectql/src/default-record-page-save-door.test.ts b/packages/objectql/src/default-record-page-save-door.test.ts index ab4aa920958..a67f3618aaa 100644 --- a/packages/objectql/src/default-record-page-save-door.test.ts +++ b/packages/objectql/src/default-record-page-save-door.test.ts @@ -53,6 +53,8 @@ const sysMetadataObject: ServiceObject = { // it, and an `email_template` write keys its row by it. bundle_member: { name: 'bundle_member', label: 'Bundle Member', type: 'text' as const, maxLength: 100 }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/protocol-publish-canonical-fold.test.ts b/packages/objectql/src/protocol-publish-canonical-fold.test.ts index f2ca91896bf..2b89868b762 100644 --- a/packages/objectql/src/protocol-publish-canonical-fold.test.ts +++ b/packages/objectql/src/protocol-publish-canonical-fold.test.ts @@ -144,6 +144,8 @@ const sysMetadataObject: ServiceObject = { // payload against this map, so omitting it is a fixture defect. package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/protocol-recorded-by-null.test.ts b/packages/objectql/src/protocol-recorded-by-null.test.ts index c6d6de56de9..1825aabef4e 100644 --- a/packages/objectql/src/protocol-recorded-by-null.test.ts +++ b/packages/objectql/src/protocol-recorded-by-null.test.ts @@ -66,6 +66,8 @@ const sysMetadataObject = { // sibling stub below is left alone.) package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/protocol-revert-org-scope.test.ts b/packages/objectql/src/protocol-revert-org-scope.test.ts index a65d717da7c..589439a226d 100644 --- a/packages/objectql/src/protocol-revert-org-scope.test.ts +++ b/packages/objectql/src/protocol-revert-org-scope.test.ts @@ -77,6 +77,8 @@ const sysMetadataObject: ServiceObject = { state: txt('state'), version: num('version'), created_at: dt('created_at'), updated_at: dt('updated_at'), created_by: txt('created_by'), updated_by: txt('updated_by'), + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: long('draft_changes'), }, }; diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index cb9ce93314f..c3f4d1d933f 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -57,6 +57,8 @@ const sysMetadataObject: ServiceObject = { // sibling stub below is left alone.) package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/publish-package-drafts-backfill.test.ts b/packages/objectql/src/publish-package-drafts-backfill.test.ts index 0d0a70e4940..5437d9d7138 100644 --- a/packages/objectql/src/publish-package-drafts-backfill.test.ts +++ b/packages/objectql/src/publish-package-drafts-backfill.test.ts @@ -33,6 +33,8 @@ const sysMetadataObject: ServiceObject = { organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/publish-package-drafts-response-conformance.test.ts b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts index dc03e1f5db4..649b12e4e12 100644 --- a/packages/objectql/src/publish-package-drafts-response-conformance.test.ts +++ b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts @@ -47,6 +47,8 @@ const sysMetadataObject: ServiceObject = { organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/save-meta-response-conformance.test.ts b/packages/objectql/src/save-meta-response-conformance.test.ts index d4627249ed5..5c78c653507 100644 --- a/packages/objectql/src/save-meta-response-conformance.test.ts +++ b/packages/objectql/src/save-meta-response-conformance.test.ts @@ -70,6 +70,8 @@ const sysMetadataObject: ServiceObject = { // sibling stub below is left alone.) package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 3bc33d03881..1d6c8950c6f 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3076,6 +3076,10 @@ export const enObjects: NonNullable = { label: "Metadata", help: "JSON-serialized metadata payload" }, + draft_changes: { + label: "Draft Changes", + help: "JSON-serialized per-key provenance of a pending draft: which surface (copilot, studio, api), actor and time last changed each key path. Null on active rows." + }, extends: { label: "Extends" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index e3adadf70bf..d9088440594 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3076,6 +3076,10 @@ export const esESObjects: NonNullable = { label: "Metadatos", help: "Payload de metadatos serializado en JSON." }, + draft_changes: { + label: "Cambios del borrador", + help: "Procedencia por clave de un borrador pendiente (JSON serializado): qué superficie (copilot, studio, api), qué actor y cuándo cambió por última vez cada ruta de clave. Nulo en las filas activas." + }, extends: { label: "Extiende" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index f15a969b1f0..c2c3a1a1857 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3076,6 +3076,10 @@ export const jaJPObjects: NonNullable = { label: "メタデータ", help: "JSON シリアライズされたメタデータペイロード" }, + draft_changes: { + label: "下書きの変更", + help: "保留中の下書きのキー単位の出所(JSON シリアライズ):各キーパスを最後に変更したサーフェス(copilot、studio、api)、操作者、日時。公開済みの行では null。" + }, extends: { label: "継承元" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index ff96b07dbfd..a2ceffed877 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3076,6 +3076,10 @@ export const zhCNObjects: NonNullable = { label: "元数据", help: "元数据负载的 JSON 序列化内容" }, + draft_changes: { + label: "草稿变更", + help: "待发布草稿的逐键来源(JSON 序列化):每个键路径最后由哪个来源(copilot、studio、api)、哪位操作者、在何时修改。已发布行为空。" + }, extends: { label: "继承自" }, diff --git a/packages/rest/src/meta-item-query-decision.test.ts b/packages/rest/src/meta-item-query-decision.test.ts index 28d7a7debf4..507f28e131d 100644 --- a/packages/rest/src/meta-item-query-decision.test.ts +++ b/packages/rest/src/meta-item-query-decision.test.ts @@ -35,22 +35,25 @@ const LAYERED_MEMBER = 'The "member" selector is not served by the layered view const refusal = (rule: string, message: string) => ({ status: 400, code: 'VALIDATION_ERROR', message, rule }); describe('[#23072] metaItemQueryRefusal — each door\'s closed set', () => { - it('the measured sets: the item read\'s six, `/layers`\' one, `/published`\' none, and the dispatcher save\'s two (no `force`)', () => { + it('the measured sets: the item read\'s six, `/layers`\' one, `/published`\' none, and the dispatcher save\'s three (no `force`)', () => { expect([...META_ITEM_READ_PARAMS].sort()).toEqual(['layers', 'locale', 'member', 'package', 'preview', 'state']); expect([...META_ITEM_LAYERS_PARAMS]).toEqual(['package']); expect([...META_ITEM_PUBLISHED_PARAMS]).toEqual([]); // RestServer's own save door reads `force`; the dispatcher's does not. - expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package']); + // [#24395] `surface` joined both save doors' sets. + expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package', 'surface']); expect(metaItemQueryRefusal('save', { force: 'true' })).toEqual(refusal( - 'unknown-parameter', unknownQueryParamMessage(['force'], ['mode', 'package']), + 'unknown-parameter', unknownQueryParamMessage(['force'], ['mode', 'package', 'surface']), )); + expect(metaItemQueryRefusal('save', { surface: 'studio', mode: 'draft' })).toBeUndefined(); + expect(metaItemQueryRefusal('save', { surface: ['studio', 'api'] })?.rule).toBe('repeated-parameter'); }); const DOORS = [ { door: 'item', set: META_ITEM_READ_PARAMS }, { door: 'layers', set: META_ITEM_LAYERS_PARAMS }, { door: 'published', set: META_ITEM_PUBLISHED_PARAMS }, - { door: 'save', set: ['package', 'mode'] }, + { door: 'save', set: ['package', 'mode', 'surface'] }, ] as const; for (const { door, set } of DOORS) { diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index c88089b5705..2e4b8a226b4 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -2270,18 +2270,18 @@ export const META_ITEM_READ_PARAMS: readonly string[] = [ * [#23072] The closed set of `PUT {metaPath}/:type/:name` (the save). * **Measured** from the handler and the helper it calls: `force` (the * destructive-change acknowledgement), `package` (the saved row's binding, - * `metaItemPackageBinding`) and `mode` — read a frame down, by - * `metaSaveRequestOptions` (`./meta-save-request.ts`), beside the `If-Match` / - * `If-None-Match` headers it reads. The capability, walled-catalog and actor - * gates read the session, not the query. The same three names the door's - * multiplicity guard declares. + * `metaItemPackageBinding`), and `mode` and [#24395] `surface` (the save's + * authoring surface) — both read a frame down, by `metaSaveRequestOptions` + * (`./meta-save-request.ts`), beside the `If-Match` / `If-None-Match` headers + * it reads. The capability, walled-catalog and actor gates read the session, + * not the query. The same four names the door's multiplicity guard declares. * * ⛔ No member selector: the save is BODY-addressed (ruling 6116165299) — the * member it writes is named by the body's own discriminator field * (`email_template`'s `locale`), and `SaveMetaItemRequest` declares no * selector. A `?member=zh-CN` beside an `en-US` body is refused, not read. */ -export const META_ITEM_SAVE_PARAMS: readonly string[] = ['force', 'package', 'mode']; +export const META_ITEM_SAVE_PARAMS: readonly string[] = ['force', 'package', 'mode', 'surface']; /** * [#23072] The closed set of `GET {metaPath}/:type/:name/layers`. @@ -2307,8 +2307,8 @@ export const META_ITEM_PUBLISHED_PARAMS: readonly string[] = []; /** * [#23072] The closed set of the runtime dispatcher's `PUT /meta/:type/:name`: * {@link META_ITEM_SAVE_PARAMS} less `force`. **Measured** on that branch of - * `handleMetadataRequest`: `package` (`metaItemPackageBinding`) and `mode` - * (`metaSaveRequestOptions`) — the names `RestServer`'s save reads through the + * `handleMetadataRequest`: `package` (`metaItemPackageBinding`), `mode` and + * `surface` (`metaSaveRequestOptions`) — the names `RestServer`'s save reads through the * same two shared readers. `force` is the one name that door was ruled never to * take (#11095, settled: its `409 DESTRUCTIVE_CHANGE` remedy already says "the * dispatcher's `PUT /meta` accepts no `force`"), so a `?force=true` there is diff --git a/packages/rest/src/meta-item-write-routes.ts b/packages/rest/src/meta-item-write-routes.ts index 395d5e6ec2c..4c937600e1e 100644 --- a/packages/rest/src/meta-item-write-routes.ts +++ b/packages/rest/src/meta-item-write-routes.ts @@ -263,8 +263,12 @@ export function registerMetaItemWriteRoutes(this: MetaItemWriteRouteHost, scope: // differently. Its `request` members are spread into the // save below, each present only when the caller asked. // + // [#24395] …and `?surface=` (`studio` | `api`; `copilot` is + // refused there), the authoring surface a draft save stamps + // on each key path it changes. + // // The multiplicity guard runs FIRST: it refuses a repeated - // `force`, `package` or `mode` (see the `force` read below + // `force`, `package`, `mode` or `surface` (see the `force` read below // for why that one is sharp) and UNWRAPS one occurrence // encoded as an array, so `?mode=` reaches the mapping as // the string it reads. @@ -275,7 +279,7 @@ export function registerMetaItemWriteRoutes(this: MetaItemWriteRouteHost, scope: // located 400 here, before anything is written, never // dropped while the body's own member is saved. if (refuseUnknownQueryParams(req, res, META_ITEM_SAVE_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return; + if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode', 'surface'])) return; const saveOptions = metaSaveRequestOptions({ headers: req.headers, query: req.query }); if (!saveOptions.ok) { res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: saveOptions.message } }); diff --git a/packages/rest/src/meta-save-request.ts b/packages/rest/src/meta-save-request.ts index 3829bd69d85..bd9129f2a2a 100644 --- a/packages/rest/src/meta-save-request.ts +++ b/packages/rest/src/meta-save-request.ts @@ -50,7 +50,26 @@ * leaves the active row alone. Any other value is an active save, as it has * always been on `RestServer`. * - * `query.mode` is read as the string a door has already judged for + * ## The authoring surface (#24395) + * + * `?surface=` is the save's `surface` (`SaveMetaItemRequestSchema`): which + * surface the save is made through, stamped by a draft save on every key path + * it changes and served on the strict draft read as `draftChanges[].by`. + * Absent, the protocol stamps `api`. A query parameter, as `?mode=` and + * `?force=` are: those are what this save is ASKED to do, while the headers + * above are HTTP's own preconditions; and a query name needs no CORS + * allow-list entry, which a custom request header from a browser on another + * origin would. + * + * 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 + * 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. + * + * `query.mode` and `query.surface` are read as the strings a door has already judged for * multiplicity. `RestServer` calls this AFTER `refuseRepeatedQueryParams`, * which refuses a repeated `mode` and unwraps one occurrence encoded as an * array; the `@objectstack/hono` catch-all flattens its query from the URL, one @@ -68,7 +87,10 @@ export interface MetaSaveRequestHttp { } /** The `saveMetaItem` members {@link metaSaveRequestOptions} answers, each present only when the request asked for it. */ -export type MetaSaveRequestMembers = Pick; +export type MetaSaveRequestMembers = Pick; + +/** [#24395] The `?surface=` values an HTTP save may state — the schema's set less `copilot`. */ +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`. */ export type MetaSaveRequestOptions = @@ -117,11 +139,30 @@ export function metaSaveRequestOptions(http: MetaSaveRequestHttp): MetaSaveReque } const mode = http.query?.mode; const draft = typeof mode === 'string' && mode.toLowerCase() === 'draft'; + const surface = http.query?.surface; + if (surface !== undefined) { + if (surface === 'copilot') { + return { + ok: false, + message: '?surface=copilot is not accepted over HTTP: it marks a write as the AI copilot\'s, ' + + 'which only an AI tool calling the save in-process may state. 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, + message: `?surface= takes studio or api, exactly; got ${JSON.stringify(surface)}. ` + + 'Omit it for a save that is not made in Studio (recorded as api).', + }; + } + } return { ok: true, request: { ...(parentVersion !== undefined ? { parentVersion } : {}), ...(draft ? { mode: 'draft' as const } : {}), + ...(surface !== undefined ? { surface: surface as 'studio' | 'api' } : {}), }, }; } 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 b71e099432d..d22fd570408 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 @@ -116,7 +116,7 @@ const DOORS = [ describe('[#23072] each by-name /meta door outside the ruling\'s list declares a closed query set', () => { it('the measured sets — none carries the member selector', () => { - expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package']); + expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package', 'surface']); expect([...META_ITEM_REFERENCES_PARAMS]).toEqual([]); expect([...META_ITEM_LAYERS_PARAMS]).toEqual(['package']); expect([...META_ITEM_AUDIT_PARAMS]).toEqual(['limit']); @@ -150,7 +150,7 @@ describe('[#23072] each by-name /meta door outside the ruling\'s list declares a const answer = await drive('PUT', ITEM, { member: 'zh-CN', mode: 'draft' }, EN); expect(answer.status).toBe(400); expect(answer.body?.error?.message).toMatch(/^The "member" query parameter is not supported by this endpoint\./); - expect(answer.body?.error?.message).toContain('Supported parameters: force, mode, package.'); + expect(answer.body?.error?.message).toContain('Supported parameters: force, mode, package, surface.'); expect(protocol.saveMetaItem).not.toHaveBeenCalled(); }); @@ -217,3 +217,51 @@ describe('[#23072] PRESERVATION — every name each door reads still reaches its expect(answer.body).toEqual(EN); }); }); + +/** + * [#24395] `?surface=` on `PUT /meta/:type/:name`: the save's authoring + * surface, read through `metaSaveRequestOptions` (the runtime dispatcher's + * half is `meta-save-preconditions-parity.test.ts`'s). Both halves per value: + * the arguments the protocol received, or a located 400 and no call at all. + */ +describe('[#24395] ?surface= on the REST save door: studio and api reach the save, copilot never does', () => { + for (const surface of ['studio', 'api'] as const) { + it(`PRESERVATION — ?surface=${surface} reaches saveMetaItem as \`surface\`, beside \`mode\``, async () => { + const { protocol, drive } = boot(); + const answer = await drive('PUT', ITEM, { mode: 'draft', surface }, EN); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1); + expect(protocol.saveMetaItem.mock.calls[0][0]).toMatchObject({ mode: 'draft', surface }); + }); + } + + it('PRESERVATION — no ?surface= sends no `surface` member: the protocol stamps api', async () => { + const { protocol, drive } = boot(); + expect((await drive('PUT', ITEM, { mode: 'draft' }, EN)).status).toBe(200); + expect('surface' in protocol.saveMetaItem.mock.calls[0][0]).toBe(false); + }); + + for (const [label, surface] of [ + ['copilot — an in-process tool\'s mark, never a remote caller\'s', 'copilot'], + ['a case variant, never folded to studio', 'Studio'], + ['an unknown value, never read as api', 'robot'], + ['an empty value', ''], + ] as const) { + it(`REFUSAL — ?surface= ${label}: 400 VALIDATION_ERROR, nothing saved`, async () => { + const { protocol, drive } = boot(); + const answer = await drive('PUT', ITEM, { mode: 'draft', surface }, EN); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toContain('surface'); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + }); + } + + it('REFUSAL — a repeated ?surface= is refused by the arity rule, nothing saved', async () => { + const { protocol, drive } = boot(); + const answer = await drive('PUT', ITEM, { surface: ['studio', 'api'] }, EN); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/meta-item-query-decision.test.ts b/packages/runtime/src/domains/meta-item-query-decision.test.ts index 239e086884e..dafe4e1b309 100644 --- a/packages/runtime/src/domains/meta-item-query-decision.test.ts +++ b/packages/runtime/src/domains/meta-item-query-decision.test.ts @@ -332,7 +332,7 @@ describe('[#23072] GET /meta/:type/:name/published on the dispatcher: its closed // ── PUT ─────────────────────────────────────────────────────────────────────── -describe('[#23072] PUT /meta/:type/:name on the dispatcher: its closed set is `package` and `mode`', () => { +describe('[#23072] PUT /meta/:type/:name on the dispatcher: its closed set is `package`, `mode` and [#24395] `surface`', () => { const ZH_BODY = MEMBERS['zh-CN']; const EN_BODY = MEMBERS['en-US']; @@ -342,7 +342,7 @@ describe('[#23072] PUT /meta/:type/:name on the dispatcher: its closed set is `p expect(answer.status).toBe(400); expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); expect(answer.body?.error?.message).toContain('"member" query parameter is not supported'); - expect(answer.body?.error?.message).toContain('Supported parameters: mode, package.'); + expect(answer.body?.error?.message).toContain('Supported parameters: mode, package, surface.'); expect(protocol.saveMetaItem).not.toHaveBeenCalled(); }); 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 96bc9ac10da..560fffd66c7 100644 --- a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts +++ b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts @@ -119,6 +119,8 @@ interface SaveOptions { headers?: Record; query?: Record; stored(name: string, state: 'active' | 'draft'): Promise; + /** [#24395] The item read through THIS door, with `query` (`{ state: 'draft' }` for the strict draft read). */ + read(name: string, query?: Record): Promise<{ status: number; envelope: any }>; } /** The refusal's `code` in either door's envelope: nested under `error`, or beside a string `error`. */ @@ -148,8 +150,17 @@ async function bootRestServer(): Promise { rest.registerRoutes(); const route = rest.getRoutes().find((r: any) => r.method === 'PUT' && r.path === `${META}/:type/:name`); if (!route) throw new Error(`PUT ${META}/:type/:name is not registered`); + const getRoute = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `${META}/:type/:name`); + if (!getRoute) throw new Error(`GET ${META}/:type/:name is not registered`); return { stored, + read: async (name, query = {}) => { + const res = makeRes(); + await getRoute.handler({ + method: 'GET', path: `${META}/view/${name}`, params: { type: 'view', name }, headers: {}, query, + }, res); + return { status: res.statusCode, envelope: res.body }; + }, save: async (name, label, { headers = {}, query = {} } = {}) => { const res = makeRes(); await route.handler({ @@ -206,6 +217,12 @@ async function bootCatchAll(): Promise { const dispatcher = dispatcherOver({ protocol }); return { stored, + read: async (name, query = {}) => { + const url = new URL(`http://localhost${META}/view/${name}`); + for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v); + const res = await catchAll(dispatcher, new Request(url, { method: 'GET' })); + return { status: res.response?.status ?? 0, envelope: res.response?.body?.data }; + }, save: async (name, label, opts = {}) => { const res = await catchAll(dispatcher, putRequest(name, label, opts)); const body = res.response?.body; @@ -342,3 +359,64 @@ describe('[#22141] the catch-all\'s fallback writer cannot carry a pin or a life expect(saveItem.mock.calls[0].slice(0, 2)).toEqual(['view', 'case_grid']); }); }); + +// ── [#24395] ?surface= and the strict draft read's draftChanges, on each door ─ + +for (const [doorName, boot] of DOORS) { + describe(`[#24395] ${doorName}: ?surface= is stamped per key path, and the strict draft read serves it`, () => { + const changes = async (door: Door, name: string) => { + const answer = await door.read(name, { state: 'draft' }); + expect(answer.status, JSON.stringify(answer.envelope)).toBe(200); + return answer.envelope.draftChanges; + }; + + it('studio, api and none each stamp `by` on the draft\'s first save; the actor is the caller\'s', async () => { + const door = await boot(); + for (const [name, query] of [ + ['s_studio', { mode: 'draft', surface: 'studio' }], + ['s_api', { mode: 'draft', surface: 'api' }], + ['s_none', { mode: 'draft' }], + ] as const) { + ok(await door.save(name, 'staged', { query })); + const entries = await changes(door, name); + expect(entries).toEqual([{ + path: [], by: 'surface' in query ? query.surface : 'api', actor: AUTHOR.userId, + at: expect.stringMatching(/Z$/), + }]); + } + }, 60_000); + + it('over a published row the first draft save records only the keys it changed, and a second save replaces at the path', async () => { + const door = await boot(); + ok(await door.save('s_base', 'live')); + ok(await door.save('s_base', 'by api', { query: { mode: 'draft', surface: 'api' } })); + const first = await changes(door, 's_base'); + expect(first.map((e: any) => [e.path, e.by])).toEqual([[['label'], 'api']]); + ok(await door.save('s_base', 'by studio', { query: { mode: 'draft', surface: 'studio' } })); + expect((await changes(door, 's_base')).map((e: any) => [e.path, e.by])).toEqual([[['label'], 'studio']]); + }, 60_000); + + it('ABSENT on the active read and on ?preview=draft', async () => { + const door = await boot(); + ok(await door.save('s_abs', 'live')); + ok(await door.save('s_abs', 'staged', { query: { mode: 'draft', surface: 'studio' } })); + expect(await changes(door, 's_abs')).toHaveLength(1); + for (const query of [{}, { preview: 'draft' }] as Array>) { + const answer = await door.read('s_abs', query); + expect(answer.status, JSON.stringify(query)).toBe(200); + expect('draftChanges' in (answer.envelope ?? {}), JSON.stringify(query)).toBe(false); + } + }, 60_000); + + it('?surface=copilot is refused 400 VALIDATION_ERROR and writes nothing: only an in-process tool states it', async () => { + const door = await boot(); + 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'); + 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([]); + expect(await door.stored('s_cop', 'active')).toEqual([]); + }, 60_000); + }); +} diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 7406fd2c61d..25cbfb0c3ac 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -59,7 +59,8 @@ import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-pr // chain, the deprecated `?layers=` flag's parse and the headers that flag is // served under. // [#22141] …and the save door's precondition and lifecycle -// (`metaSaveRequestOptions`): `If-Match` / `If-None-Match: *` and `?mode=draft`. +// (`metaSaveRequestOptions`): `If-Match` / `If-None-Match: *` and `?mode=draft` +// — [#24395] and `?surface=` (`studio` | `api`, `copilot` refused). // [#22188] …and the one reading of `?package=` (`metaItemPackageBinding`), // under which `all` names no package, on every branch here that reads it. // [#23072] …and the by-name doors' query decision (`metaItemQueryRefusal`, @@ -1581,7 +1582,10 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin ...(packageId ? { packageId } : {}), // [#22141] `parentVersion` and `mode`, each present // only when the caller asked — read off the headers and - // the query above, never off `item`. + // the query above, never off `item`. [#24395] So is + // `surface`: `?surface=studio|api`, the authoring + // surface a draft save stamps per key path, and never + // `copilot`, which the mapping refuses on this door. ...saveOptions.request, ...(actor ? { actor } : {}), }); diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 82d567ef207..4980634e587 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -621,6 +621,8 @@ "MarkNotificationsReadRequestSchema (const)", "MarkNotificationsReadResponse (type)", "MarkNotificationsReadResponseSchema (const)", + "MetadataAuthoringSurface (type)", + "MetadataAuthoringSurfaceSchema (const)", "MetadataBulkRegisterRequest (type)", "MetadataBulkRegisterRequestParsed (type)", "MetadataBulkRegisterRequestSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 350090e90fc..c17a2b4fc4e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1592,6 +1592,7 @@ "api/SaveMetaItemRequest:name", "api/SaveMetaItemRequest:packageId", "api/SaveMetaItemRequest:parentVersion", + "api/SaveMetaItemRequest:surface", "api/SaveMetaItemRequest:type", "api/SaveMetaItemRequest:writeFace", "api/SaveMetaItemResponse:advisories", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index c0bff12d876..16edaaacd34 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -458,6 +458,8 @@ "MarkNotificationsReadRequestSchema": "api/MarkNotificationsReadRequest", "MarkNotificationsReadResponse": "api/MarkNotificationsReadResponse", "MarkNotificationsReadResponseSchema": "api/MarkNotificationsReadResponse", + "MetadataAuthoringSurface": "api/MetadataAuthoringSurface", + "MetadataAuthoringSurfaceSchema": "api/MetadataAuthoringSurface", "MetadataBulkRegisterRequest": "api/MetadataBulkRegisterRequest", "MetadataBulkRegisterRequestSchema": "api/MetadataBulkRegisterRequest", "MetadataBulkResponse": "api/MetadataBulkResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 1d3a90d157f..96c8fd03379 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -595,6 +595,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)", + "MetadataAuthoringSurface": "src/api/protocol.zod.ts#MetadataAuthoringSurface (type)", + "MetadataAuthoringSurfaceSchema": "src/api/protocol.zod.ts#MetadataAuthoringSurfaceSchema (const)", "MetadataBulkRegisterRequest": "src/api/metadata.zod.ts#MetadataBulkRegisterRequest (type)", "MetadataBulkRegisterRequestParsed": "src/api/metadata.zod.ts#MetadataBulkRegisterRequestParsed (type)", "MetadataBulkRegisterRequestSchema": "src/api/metadata.zod.ts#MetadataBulkRegisterRequestSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 92e1ce3bf34..923f98997ad 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -253,6 +253,7 @@ "api/MarkAllNotificationsReadResponse", "api/MarkNotificationsReadRequest", "api/MarkNotificationsReadResponse", + "api/MetadataAuthoringSurface", "api/MetadataBulkRegisterRequest", "api/MetadataBulkResponse", "api/MetadataBulkUnregisterRequest", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 2d64babedb4..679e08f8206 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -479,6 +479,21 @@ const MetadataProtectionEnvelopeFields = { ), } as const; +/** + * [#24395] The authoring surface of a metadata save: the one closed set both + * {@link SaveMetaItemRequestSchema}'s `surface` (what a save declares) and + * {@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. + */ +export const MetadataAuthoringSurfaceSchema = z.enum(['copilot', '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`.', +); +export type MetadataAuthoringSurface = z.input; + /** * Get Metadata Item Response * @@ -582,8 +597,15 @@ const MetadataProtectionEnvelopeFields = { * and the array's entry says who last changed it and when. Attribution is * per array, never per element. * - **Optional, never defaulted to `[]`.** Absent means "not recorded" (a - * server with no writer, or a draft row saved before one); an empty-array - * default would answer "nothing changed" for exactly those rows. + * draft row saved before the writer, or one whose saves changed no key + * path); an empty-array default would answer "nothing changed" for exactly + * those rows. + * + * [#24395] The writer is the draft save (`saveMetaItem` with `mode: 'draft'`, + * in `@objectstack/metadata-protocol`). It records on the draft row, beside the + * body and never inside it, so publishing or discarding the draft removes the + * entries with the row. Each save names its surface on + * {@link SaveMetaItemRequestSchema}'s `surface`. * * The history log's per-event `source` (see `HistoryMetaItemResponseSchema`) * is not this vocabulary: it is an open label of the code path that wrote the @@ -643,8 +665,9 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ + '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: z.enum(['copilot', 'studio', 'api']).describe( - 'The authoring surface of the write that last changed this path. Closed: `copilot` is an AI ' + 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`.', @@ -660,13 +683,14 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ }).describe('One changed key path of the pending draft, and the last write that changed it.')) .optional().describe( 'Per-key provenance of the pending draft: which authoring surface last changed each key path of ' - + '`item`, under which actor, and when. No server writes the key yet: until the engine\'s draft ' - + 'save records it, every draft read is one whose saves recorded none, and the key is absent. ' - + 'The contract a writer must meet: it compares each draft save with the body that save replaced ' - + '(the previous draft, or the published body for the draft\'s first save); it keeps the entries ' + + '`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 it keeps no two entries on one path, replacing every entry at ' - + 'or under a path a save changes, so the last write to change a key is the entry whose `path` is ' + + '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 ' @@ -866,6 +890,12 @@ export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({ * it — declaring it here would advertise a wire-authorable provenance * channel the REST layer deliberately never reads. * + * [#24395] `surface` is the save's authoring surface, the closed set + * `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`. + * * `writeFace` IS declared, and the distinction with `source` is the point: * both are server-stated, but `writeFace` is sent by three doors (the two * metadata write doors and the external-table import) and the @@ -959,6 +989,16 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ + 'message adapts to whether a structured `issues[]` channel reaches ' + 'the consumer beside it. Absent = the conservative default wording.', ), + surface: MetadataAuthoringSurfaceSchema.optional().describe( + '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.', + ), })); /** diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 903eb3aa19b..673e2d7c1b6 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -277,7 +277,7 @@ import type * as M189 from './system/fate-supplement.zod.js'; import type * as M190 from './kernel/execution-context.zod.js'; // --------------------------------------------------------------------------- -// 788 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 789 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 @@ -607,6 +607,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__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 > >>; export type Iso_api_protocol__PublishPackageDraftsResponseSchema = Assert, z.infer< typeof M28.PublishPackageDraftsResponseSchema > >>; @@ -1702,7 +1703,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 788 isomorphic pins', () => { + it('still declares all 789 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 @@ -2545,7 +2546,13 @@ describe('ADR-0122 type-alias convention', () => { // 789 -> 788 is #24162: `RecordJsonProps` left with the retired // `record:json` element, so its pin (Iso_ui_component__RecordJsonProps) // left with it. -1. - expect(pins).toHaveLength(788); + // + // 788 -> 789 is #24395: `MetadataAuthoringSurfaceSchema` + // (`api/protocol.zod.ts`), the closed authoring-surface set a save states + // and `draftChanges[].by` reports — a plain string enum, so it is pinned + // here (Iso_api_protocol__MetadataAuthoringSurfaceSchema) rather than + // given a `…Parsed` synonym. +1 added. + expect(pins).toHaveLength(789); // 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