From e0131040f736679b2bf13b42594c28b8154ffec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:53:19 +0000 Subject: [PATCH] feat(spec,client): declare the publish door's response contract (#7294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/v1/meta/:type/:name/publish` is served by `@objectstack/rest` (`rest-server.ts` registers it and hands `publishMetaItem`'s return straight to `res.json()`), and had no declaration behind it: `PublishMetaItem` appeared nowhere under `packages/spec/src/`, and the route was absent from `plugin-rest-api.zod.ts`'s metadata table. So `version` on the publish response sat in exactly the state `version` on the save response sat in before #5745 — the ADR-0008 OCC token, echoed back as `If-Match` to get a 409 instead of a lost update, on a public wire surface with nothing declaring it. This carries the #5745 "declared = returned" discipline one door over, with the same three artifacts: - `PublishMetaItemResponseSchema` declares the FULL measured body: `success` / `version` / `seq` required, `message` plus the three conditional side-effect receipts (`seedApplied` / `materializeApplied` / `projectionApplied`) optional. Measured from the producer, not assumed — its single response literal always sets the first three and attaches each receipt only when the matching side effect ran, so an absent receipt means "did not run", never "failed". - The endpoint declaration, matching the five sibling metadata entries. No `requestSchema`: the body's only read key is `message`, taken only when already a string, so the route cannot 400 a malformed body and declaring one would advertise a gate that does not run (#3899). - `packages/objectql/src/publish-meta-response-conformance.test.ts` — the producer-side gate mirroring the save door's, driving a real `publishMetaItem` against a real ObjectQL engine through the schema across the plain shape and every receipt path. Also: - `client.metadata.publishItem()` is typed `Promise` and the type re-exported, matching `saveItem` / `SaveMetaItemResponse`. It resolved to `any` before, for want of a declaration to point at. - `publishMetaItem`'s own `Promise<...>` annotation omitted `projectionApplied` while the body assigned it — the same declared-≠-returned gap one layer down. No behavior change: nothing about the response body moved. Closes #7294 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DuV91PKtmJ8UFUtDjqXy5h --- .../declare-publish-meta-item-response.md | 49 +++ content/docs/references/api/protocol.mdx | 21 +- content/docs/references/index.mdx | 10 +- ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/client/src/index.ts | 22 +- packages/metadata-protocol/src/protocol.ts | 12 + .../publish-meta-response-conformance.test.ts | 317 ++++++++++++++++++ packages/spec/api-surface/api.json | 2 + packages/spec/authorable-surface/api.json | 7 + packages/spec/export-origins/api.json | 2 + packages/spec/json-schema.manifest/api.json | 1 + packages/spec/src/api/plugin-rest-api.test.ts | 10 +- packages/spec/src/api/plugin-rest-api.zod.ts | 29 +- packages/spec/src/api/protocol.test.ts | 105 ++++++ packages/spec/src/api/protocol.zod.ts | 115 +++++++ .../src/type-alias-convention.pin.test.ts | 19 +- 16 files changed, 708 insertions(+), 15 deletions(-) create mode 100644 .changeset/declare-publish-meta-item-response.md create mode 100644 packages/objectql/src/publish-meta-response-conformance.test.ts diff --git a/.changeset/declare-publish-meta-item-response.md b/.changeset/declare-publish-meta-item-response.md new file mode 100644 index 0000000000..d50c5b560b --- /dev/null +++ b/.changeset/declare-publish-meta-item-response.md @@ -0,0 +1,49 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": patch +"@objectstack/client": patch +--- + +feat(spec,client): declare the publish door's response — `PublishMetaItemResponseSchema` (#7294) + +`POST /api/v1/meta/:type/:name/publish` has been served since long before this +change, and had no contract behind it: the string `PublishMetaItem` appeared +nowhere under `packages/spec/src/`, and the endpoint was absent from +`plugin-rest-api.zod.ts`'s metadata table. So `version` on the publish response +sat in exactly the state `version` on the *save* response sat in before #5745 — +the ADR-0008 optimistic-concurrency token, the value a caller echoes back as +`If-Match` to get a 409 instead of a lost update, riding a public wire surface +with nothing declaring it. `PublishMetaItemResponse` could not be named at the +type level either, which is why `client.metadata.publishItem()` resolved to +`any`. + +This carries the #5745 "declared = returned" discipline one door over, with the +same three artifacts the save door has: + +- **`PublishMetaItemResponseSchema`** declares the FULL measured body — + `success` / `version` / `seq` required, `message` and the three conditional + side-effect receipts (`seedApplied` / `materializeApplied` / + `projectionApplied`) optional. Optionality is measured, not assumed: the sole + producer's single response literal always sets the first three, and attaches + each receipt only when the matching side effect ran, so an absent receipt + means "that side effect did not run", never "it failed". +- **The endpoint declaration**, so the catalog names the route it serves and + points at the schema. No `requestSchema`: the body's only read key is + `message`, taken only when already a string, so the route cannot 400 a + malformed body and declaring one would advertise a gate that does not run. +- **A producer-side conformance gate** + (`publish-meta-response-conformance.test.ts`), driving a real + `publishMetaItem` against a real ObjectQL engine through the schema across + the plain shape and every receipt path. A field added to the response, or + dropped from the schema, now turns that red instead of silently vanishing at + parse. + +`client.metadata.publishItem()` is typed `Promise` and +the type is re-exported, matching `saveItem` / `SaveMetaItemResponse`. + +Also fixes a declared-≠-returned gap one layer down: `publishMetaItem`'s own +`Promise<...>` annotation omitted `projectionApplied` while the implementation +assigned it, so the method's type denied a key its callers were receiving. + +No behavior change — nothing about the response body moved. This declares what +was already on the wire. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 3bedd38c2c..064ee4e032 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -12,8 +12,8 @@ description: Protocol protocol schemas ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, 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, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, 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, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, 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, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, PublishMetaItemResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, 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); @@ -1197,6 +1197,23 @@ List packages response | **channels** | `Record` | optional | Per-channel notification preferences | +--- + +## PublishMetaItemResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **success** | `boolean` | ✅ | Always true on a 2xx — the draft was promoted. It does NOT cover the best-effort side effects below, each of which reports its own `success`. | +| **version** | `string` | ✅ | Content hash of the just-promoted body, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is reported as 409 `metadata_conflict` instead of silently overwritten. Opaque to callers — echo it verbatim, never parse it. Currently emitted as `sha256:<64 hex chars>`, but the format is not part of this contract. | +| **seq** | `integer` | ✅ | Monotonic sequence number of the `op='publish'` metadata event this promotion appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | +| **seedApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string; … }` | optional | Outcome of materializing a published `seed` body into data rows. Present ONLY when the published type is `seed` — publishing a seed is what makes its rows live, so the load rides along with the metadata promotion. Best-effort: a seed-load problem is surfaced here, never thrown, so a caller must check `seedApplied.success` instead of assuming the 200 covered the data. Absent on the batch path, which suppresses the per-item apply and loads every seed body in one later pass. | +| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string }` | optional | Outcome of the ADR-0086 P2 publish-time materializer — the step that projects the published body into its data-plane row (e.g. `permission` → `sys_permission_set`, under the owning package). Present ONLY when a materializer is registered for this metadata type, which is why it is optional: its absence means "no materializer ran", never "it failed". Best-effort, same contract as `seedApplied`. | +| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model. The same receipt `{@link SaveMetaItemResponseSchema}` carries, because the projector runs on BOTH write doors: a direct active save and this draft→active promotion. Present ONLY when a projector is registered for this metadata type. Best-effort — a projector failure is reported here and logged, never thrown. | +| **message** | `string` | optional | Human-readable receipt, e.g. `Published draft — type=view, name=cases [seq=3]`. The producer sets it on every publish today; it stays optional to match the producer's own signature and its `SaveMetaItemResponse` twin, and because an absent human-readable string strips no data — the failure mode #5745 exists to prevent. | + + --- ## RealtimeConnectRequest diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 4ec32151e2..654c4a6674 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1575 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1576 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/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 28 | 410 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 28 | 411 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 30 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 292 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 147 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1575** | 14 protocol modules | +| **Total** | **200** | **1576** | 14 protocol modules | --- @@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **28 pages, 410 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **28 pages, 411 schemas** REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. @@ -86,7 +86,7 @@ REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | [`odata.zod.ts`](/docs/references/api/odata) | `ODataConfig`, `ODataError`, `ODataFilterFunction`, `ODataMetadata`, `ODataQuery`, `ODataResponse` | | [`package-api.zod.ts`](/docs/references/api/package-api) | `GetInstalledPackageRequest`, `GetInstalledPackageResponse`, `ListInstalledPackagesRequest`, `ListInstalledPackagesResponse`, `PackageApiErrorCode`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageRollbackResponse`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `HandlerStatus`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `RouteCoverageEntry`, `RouteCoverageReport`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `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`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 9b00491a9b..1ac17f5250 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -264,7 +264,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 393 | +| `api/` | 397 | | `cloud/` | 83 | | `identity/` | 33 | | `integration/` | 10 | diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b6fcc79d08..9eb9f543d0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -16,6 +16,7 @@ import { GetMetaItemsResponse, GetMetaItemResponse, SaveMetaItemResponse, + PublishMetaItemResponse, LoginRequest, SessionResponse, GetPresignedUrlRequest, @@ -929,14 +930,30 @@ export class ObjectStackClient { * the per-item flow beside `packages.publishDrafts`' package-scoped one. * 404 [no_draft] when there is nothing to publish. Compound names pass * through unencoded, like `getItem`. + * + * The resolved `version` is the ADR-0008 optimistic-concurrency token, the + * same carrier `saveItem` returns and with the same job: echo it back as + * `If-Match` on the next write to the item. It is nameable here only since + * #7294, which declared `PublishMetaItemResponseSchema` — this method + * resolved to `any` before that, because the publish door had no + * declaration at all for a return type to point at. + * + * The three `*Applied` receipts are each present only when their side + * effect ran, and each reports its own `success`: a 200 here means the + * draft was promoted, NOT that a seed load or a data-plane projection + * caught up. */ - publishItem: async (type: string, name: string, opts?: { message?: string }) => { + publishItem: async ( + type: string, + name: string, + opts?: { message?: string }, + ): Promise => { const route = this.getRoute('metadata'); const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/publish`, { method: 'POST', body: JSON.stringify(opts?.message ? { message: opts.message } : {}), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** @@ -5320,6 +5337,7 @@ export type { GetMetaItemsResponse, GetMetaItemResponse, SaveMetaItemResponse, + PublishMetaItemResponse, CheckPermissionRequest, CheckPermissionResponse, GetObjectPermissionsResponse, diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9763b804f4..ff988a2fd3 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9766,6 +9766,18 @@ export class ObjectStackProtocolImplementation implements * same contract as `seedApplied` — surfaced, never thrown. */ materializeApplied?: PublishMaterializeResult; + /** + * Present when an ADR-0094 mutation projector is registered for this + * type: the outcome of the awaited post-persist projection. The + * draft→active promotion runs the projector exactly as a direct active + * save does, so this is the same receipt `saveMetaItem` returns. + * + * [#7294] It was ASSIGNED below and missing from this annotation, so + * the method's declared type denied a key the wire body carried — the + * same declared-≠-returned gap one layer down from the one #7294 + * closed on the spec side. + */ + projectionApplied?: MutationProjectionOutcome; }> { const { singularType, orgId, result } = await this.promoteDraftForPublish(request); const response: { diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts new file mode 100644 index 0000000000..c110df236b --- /dev/null +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -0,0 +1,317 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7294 — conformance gate: the body `publishMetaItem` really returns must + * parse through `PublishMetaItemResponseSchema` with NOTHING stripped. + * + * This is the producer side of the declaration. The spec-side suite + * (`packages/spec/src/api/protocol.test.ts`) pins what the schema says; this + * one pins that the schema still matches what the code emits, driving the REAL + * protocol against a REAL ObjectQL engine. The two together are what makes + * "declared = returned" checkable — a future field added to the response, or an + * existing one dropped, turns this red instead of silently vanishing at parse. + * + * Why the REST layer needs no separate case: the route hands this exact object + * to `res.json()` verbatim (`rest-server.ts`, `POST /meta/:type/:name/publish`), + * so the protocol return IS the wire body. + * + * The exact shape of the sibling gate one door over + * (`save-meta-response-conformance.test.ts`, #5745) — deliberately, because the + * publish door is the same class of surface and had none of its three + * artifacts: before #7294 the string `PublishMetaItem` appeared nowhere under + * `packages/spec/src/`, so there was no schema for a gate to check against and + * `version` — the ADR-0008 OCC token — rode the wire with no contract behind + * it. That is the direction this must never drift back to. + * + * The publish door's extra surface over the save door is the three conditional + * receipts (`seedApplied` / `materializeApplied` / `projectionApplied`), so the + * cases below cover both the plain shape and each conditional path — a receipt + * that appears in a shape the schema does not carry is the failure this + * catches. + */ +import { describe, it, expect } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { PublishMetaItemResponseSchema } from '@objectstack/spec/api'; +import { ObjectQL } from './engine.js'; + +const sysMetadataObject: ServiceObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', 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 }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +/** + * The same in-memory driver the save-door gate uses. Kept local rather than + * shared: `save-meta-response-conformance.test.ts` and + * `plugin.authoring-channel.test.ts` each carry their own copy, so a + * self-contained harness is the established shape here, and a gate that + * imports its own substrate from another gate's file couples two tripwires + * that must be able to fail independently. + */ +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w)); + if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w)); + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +async function makeProtocol() { + const engine = new ObjectQL(); + const { driver } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject, 'test-package'); + return new ObjectStackProtocolImplementation(engine); +} + +const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'] }); + +/** + * A `seed` body, which is what makes `seedApplied` appear on the response. It + * carries no `name` key: `SeedSchema` is strict (#4001) and the item's name + * lives on the metadata ref, not inside the body. Targets `sys_metadata` + * because that is the one object this harness registers. + */ +const seedBody = { object: 'sys_metadata', records: [{ name: 'row_a', type: 'view' }] }; + +const ORG = 'org_x'; + +/** + * Stage a draft and promote it — the two-step the REST pair + * `PUT /:type/:name?mode=draft` + `POST /:type/:name/publish` spells. There is + * no one-shot publish: `publishMetaItem` promotes an EXISTING draft and 404s + * (`[no_draft]`) without one, so the save is part of the fixture, not a + * second thing under test. + */ +async function stageAndPublish( + p: ObjectStackProtocolImplementation, + opts: { type?: string; name?: string; item?: unknown; org?: string | null } = {}, +): Promise { + const type = opts.type ?? 'view'; + const name = opts.name ?? 'cases'; + const item = opts.item ?? viewBody('A'); + // `org: null` writes env-wide. Not a stylistic choice: ADR-0005 /#6190 refuse + // an org-scoped write for a type whose registry entry says + // `allowOrgOverride: false`, and `seed` is one — the row would not survive a + // restart, so the platform declines to mint it. The seed cases below are + // therefore env-wide, which is the only channel that type has. + const scope = opts.org === undefined ? ORG : opts.org; + const orgArg = scope === null ? {} : { organizationId: scope }; + await (p as any).saveMetaItem({ type, name, ...orgArg, item, mode: 'draft' }); + return (p as any).publishMetaItem({ type, name, ...orgArg }); +} + +/** Keys the producer emitted that the schema refused to carry through. */ +function strippedKeys(raw: Record): string[] { + const parsed = PublishMetaItemResponseSchema.parse(raw) as Record; + return Object.keys(raw).filter((k) => !(k in parsed)); +} + +describe('publishMetaItem response conforms to PublishMetaItemResponseSchema (#7294)', () => { + it('plain publish: parses green and strips nothing', async () => { + const p = await makeProtocol(); + const raw: any = await stageAndPublish(p); + + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishMetaItemResponseSchema.parse(raw); + expect(parsed.success).toBe(true); + expect(typeof parsed.seq).toBe('number'); + expect(Number.isInteger(parsed.seq)).toBe(true); + // The ADR-0008 OCC token survives parse — this is the value a caller + // echoes back as `If-Match` on the next write to this item. It is the + // field whose undeclared state on this door was the #7294 finding. + expect(parsed.version).toBe(raw.version); + expect(typeof parsed.version).toBe('string'); + expect(parsed.message).toBe(raw.message); + }); + + it('success / version / seq are required — the producer always emits them', async () => { + const p = await makeProtocol(); + const raw: any = await stageAndPublish(p); + + for (const key of ['success', 'version', 'seq'] as const) { + expect(raw[key], `producer must emit '${key}'`).toBeDefined(); + const body: Record = { ...raw }; + delete body[key]; + expect( + PublishMetaItemResponseSchema.safeParse(body).success, + `omitting '${key}' must fail parse`, + ).toBe(false); + } + }); + + it('no side effects registered → all three receipts are absent, which is why each is optional', async () => { + const p = await makeProtocol(); + const raw: any = await stageAndPublish(p); + + expect(raw.seedApplied).toBeUndefined(); + expect(raw.materializeApplied).toBeUndefined(); + expect(raw.projectionApplied).toBeUndefined(); + expect(PublishMetaItemResponseSchema.safeParse(raw).success).toBe(true); + }); + + it('with an ADR-0094 projector registered: projectionApplied is carried through', async () => { + const p = await makeProtocol(); + p.registerMutationProjector('view', async () => { throw new Error('boom-from-projector'); }); + + const raw: any = await stageAndPublish(p); + + expect(Object.keys(raw)).toContain('projectionApplied'); + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishMetaItemResponseSchema.parse(raw); + // Best-effort by contract: the projector threw, the promotion still + // succeeded, and the failure is reported here rather than as a non-200. + expect(parsed.success).toBe(true); + expect(parsed.projectionApplied).toEqual({ success: false, error: 'boom-from-projector' }); + }); + + it('with an ADR-0086 P2 materializer registered: materializeApplied is carried through', async () => { + const p = await makeProtocol(); + p.registerPublishMaterializer('view', async () => ({ success: true, inserted: 2, updated: 1 })); + + const raw: any = await stageAndPublish(p); + + expect(Object.keys(raw)).toContain('materializeApplied'); + expect(strippedKeys(raw)).toEqual([]); + expect(PublishMetaItemResponseSchema.parse(raw).materializeApplied) + .toEqual({ success: true, inserted: 2, updated: 1 }); + }); + + it('a throwing materializer: the failure shape (success:false + error) is carried through too', async () => { + const p = await makeProtocol(); + p.registerPublishMaterializer('view', async () => { throw new Error('boom-from-materializer'); }); + + const raw: any = await stageAndPublish(p); + + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishMetaItemResponseSchema.parse(raw); + // Surfaced, never thrown — the publish itself still reports success. + expect(parsed.success).toBe(true); + expect(parsed.materializeApplied) + .toEqual({ success: false, inserted: 0, updated: 0, error: 'boom-from-materializer' }); + }); + + it('publishing a `seed`: seedApplied is carried through with its counters', async () => { + const p = await makeProtocol(); + const raw: any = await stageAndPublish(p, { + type: 'seed', + name: 'demo_rows', + org: null, + item: seedBody, + }); + + expect(Object.keys(raw)).toContain('seedApplied'); + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishMetaItemResponseSchema.parse(raw); + expect(parsed.success).toBe(true); + // The load really ran — this is the SUCCESS branch with a row actually + // inserted, not the surfaced-failure fallback. Asserting the counters + // rather than their types is what makes the case a measurement: a + // receipt whose numbers stopped arriving would still be "a number" if + // the schema ever loosened them. + expect(parsed.seedApplied).toEqual({ success: true, inserted: 1, updated: 0 }); + }); + + it('all three receipts at once: the fully-loaded body still strips nothing', async () => { + const p = await makeProtocol(); + p.registerPublishMaterializer('seed', async () => ({ success: true, inserted: 1, updated: 0 })); + p.registerMutationProjector('seed', async () => { /* clean projection */ }); + + const raw: any = await stageAndPublish(p, { + type: 'seed', + name: 'demo_rows', + org: null, + item: seedBody, + }); + + for (const key of ['seedApplied', 'materializeApplied', 'projectionApplied']) { + expect(Object.keys(raw), `producer must emit '${key}' on this path`).toContain(key); + } + expect(strippedKeys(raw)).toEqual([]); + expect(PublishMetaItemResponseSchema.parse(raw).projectionApplied).toEqual({ success: true }); + }); + + it('a non-draftable type is refused outright rather than promoted without a receipt', async () => { + // The tripwire for the `required` decision on success / version / seq: + // `publishMetaItem` has exactly ONE success return — the promotion path + // — and it always sets all three. The gate exercised here is what keeps + // a second, receipt-less promotion path from appearing. If it is ever + // relaxed so a type reaches `active` some other way, whatever receipt + // THAT path returns has to be re-measured before these three fields can + // stay required. + const p = await makeProtocol(); + await expect( + (p as any).publishMetaItem({ type: 'agent', name: 'helper', organizationId: ORG }), + ).rejects.toMatchObject({ status: 403 }); + }); +}); diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 73a59c2347..1c5a7d96a3 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -728,6 +728,8 @@ "PresignedUrlResponse (type)", "PresignedUrlResponseParsed (type)", "PresignedUrlResponseSchema (const)", + "PublishMetaItemResponse (type)", + "PublishMetaItemResponseSchema (const)", "QueryAdapterConfig (type)", "QueryAdapterConfigParsed (type)", "QueryAdapterConfigSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 599d0d85af..a366006bd8 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1258,6 +1258,13 @@ "api/PresignedUrlResponse:error", "api/PresignedUrlResponse:meta", "api/PresignedUrlResponse:success", + "api/PublishMetaItemResponse:materializeApplied", + "api/PublishMetaItemResponse:message", + "api/PublishMetaItemResponse:projectionApplied", + "api/PublishMetaItemResponse:seedApplied", + "api/PublishMetaItemResponse:seq", + "api/PublishMetaItemResponse:success", + "api/PublishMetaItemResponse:version", "api/QueryAdapterConfig:odata", "api/QueryAdapterConfig:operatorMappings", "api/QueryAdapterConfig:rest", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 05b1d4a5cd..1232505728 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -728,6 +728,8 @@ "PresignedUrlResponse": "src/api/storage.zod.ts#PresignedUrlResponse (type)", "PresignedUrlResponseParsed": "src/api/storage.zod.ts#PresignedUrlResponseParsed (type)", "PresignedUrlResponseSchema": "src/api/storage.zod.ts#PresignedUrlResponseSchema (const)", + "PublishMetaItemResponse": "src/api/protocol.zod.ts#PublishMetaItemResponse (type)", + "PublishMetaItemResponseSchema": "src/api/protocol.zod.ts#PublishMetaItemResponseSchema (const)", "QueryAdapterConfig": "src/api/query-adapter.zod.ts#QueryAdapterConfig (type)", "QueryAdapterConfigParsed": "src/api/query-adapter.zod.ts#QueryAdapterConfigParsed (type)", "QueryAdapterConfigSchema": "src/api/query-adapter.zod.ts#QueryAdapterConfigSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 83e3e373ee..a5e53a3ac0 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -301,6 +301,7 @@ "api/PresenceStatus", "api/PresenceUpdate", "api/PresignedUrlResponse", + "api/PublishMetaItemResponse", "api/QueryAdapterConfig", "api/QueryAdapterTarget", "api/QueryOptimizationConfig", diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts index 17b1166ee3..b882fb6ea9 100644 --- a/packages/spec/src/api/plugin-rest-api.test.ts +++ b/packages/spec/src/api/plugin-rest-api.test.ts @@ -475,10 +475,18 @@ describe('plugin-rest-api.zod', () => { // projection, previously reachable only as an undeclared `?layers=true` // variant of `GET /:type/:name`, now its own path with its own // `GetMetaItemLayeredResponseSchema`. - expect(DEFAULT_METADATA_ROUTES.endpoints).toHaveLength(5); + // 5 -> 6: `POST /:type/:name/publish` (#7294) — the draft→active + // promotion door. Served by `@objectstack/rest` all along and absent + // from this table, so its response body had no contract behind it; it + // now declares `PublishMetaItemResponseSchema`, the #5745 discipline one + // door over from `PUT /:type/:name`. + expect(DEFAULT_METADATA_ROUTES.endpoints).toHaveLength(6); expect(DEFAULT_METADATA_ROUTES.endpoints?.map((e) => `${e.method} ${e.path}`)).toContain( 'GET /:type/:name/layers', ); + expect(DEFAULT_METADATA_ROUTES.endpoints?.map((e) => `${e.method} ${e.path}`)).toContain( + 'POST /:type/:name/publish', + ); expect(DEFAULT_METADATA_ROUTES.middleware).toBeDefined(); }); diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index 91aa3a54f5..5f2efff1b2 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -736,7 +736,10 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { prefix: '/api/v1/meta', service: 'metadata', category: 'metadata', - methods: ['getMetaTypes', 'getMetaItems', 'getMetaItem', 'getMetaItemLayered', 'saveMetaItem'], + methods: [ + 'getMetaTypes', 'getMetaItems', 'getMetaItem', 'getMetaItemLayered', 'saveMetaItem', + 'publishMetaItem', + ], authRequired: true, endpoints: [ { @@ -825,6 +828,30 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { permissions: ['metadata.write'], cacheable: false, }, + { + method: 'POST', + path: '/:type/:name/publish', + handler: 'publishMetaItem', + category: 'metadata', + public: false, + summary: 'Publish the pending draft overlay (promotes draft → active)', + description: + 'Promotes the item\'s pending DRAFT overlay to the live `active` row and records an ' + + '`op=\'publish\'` history event. The sibling write door of `PUT /:type/:name` — the ' + + 'ADR-0033 two-step spelling, where `?mode=draft` stages a body and this makes it live. ' + + '404 `[no_draft]` when there is nothing to publish; 409 `metadata_conflict` when the ' + + 'published row advanced while the draft was held. Served since before #7294 with no ' + + 'declaration behind it — this entry is what makes its response contract nameable.', + tags: ['Metadata'], + // No `requestSchema` (#3899): the body is optional and its only read key + // is `message`, taken only when it is already a string and ignored + // otherwise — the route cannot 400 a malformed body, so declaring a + // schema here would advertise a gate that does not run. Every other + // input (`:type`, `:name`) is path-bound. + responseSchema: 'PublishMetaItemResponseSchema', + permissions: ['metadata.write'], + cacheable: false, + }, ], middleware: [ { name: 'auth', type: 'authentication', enabled: true, order: 10 }, diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 8ab8551048..36b68c59eb 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -750,3 +750,108 @@ describe('SaveMetaItemResponseSchema (#5745 — declares the full save response) ).toBe(false); }); }); + +import { PublishMetaItemResponseSchema } from './protocol.zod'; + +/** + * #7294 — the same suite one door over, for `POST /meta/:type/:name/publish`. + * + * The publish door had NO declaration at all: `PublishMetaItem` appeared + * nowhere under `packages/spec/src/`, so its `version` — the same ADR-0008 OCC + * token the save door already declares — rode the wire with no contract behind + * it, and `PublishMetaItemResponse` could not be named at the type level. + * + * Optionality is measured, not assumed (evidence in the PR): the sole producer + * is `ObjectStackProtocolImplementation.publishMetaItem`, whose single response + * literal always sets `success` / `version` / `seq`, and attaches each of the + * three `*Applied` receipts only when the matching side effect ran. + */ +describe('PublishMetaItemResponseSchema (#7294 — declares the full publish response)', () => { + /** A verbatim capture of a real `publishMetaItem` return (promotion path). */ + const realResponse = { + success: true, + version: 'sha256:7aad99c8d969efb5067fff275fb3e5be7ec90f9cd610d41709fcddbf8c34b1f0', + seq: 2, + message: 'Published draft — type=view, name=cases [seq=2]', + }; + + it('round-trips a real response without stripping any field', () => { + const parsed = PublishMetaItemResponseSchema.parse(realResponse); + expect(Object.keys(parsed).sort()).toEqual(Object.keys(realResponse).sort()); + expect(parsed).toEqual(realResponse); + }); + + it('carries the ADR-0008 OCC token: version survives parse as the If-Match value', () => { + expect(PublishMetaItemResponseSchema.parse(realResponse).version).toBe(realResponse.version); + }); + + it('keeps seq as an integer and rejects a fractional one', () => { + expect(PublishMetaItemResponseSchema.parse(realResponse).seq).toBe(2); + expect(PublishMetaItemResponseSchema.safeParse({ ...realResponse, seq: 2.5 }).success).toBe(false); + }); + + it('requires success / version / seq — the producer always emits them', () => { + for (const missing of ['success', 'version', 'seq'] as const) { + const body: Record = { ...realResponse }; + delete body[missing]; + expect( + PublishMetaItemResponseSchema.safeParse(body).success, + `omitting '${missing}' must fail parse`, + ).toBe(false); + } + }); + + it('carries seedApplied — present only when a `seed` was published', () => { + expect(PublishMetaItemResponseSchema.safeParse(realResponse).success).toBe(true); + const withSeed = PublishMetaItemResponseSchema.parse({ + ...realResponse, + seedApplied: { success: true, inserted: 3, updated: 1 }, + }); + expect(withSeed.seedApplied).toEqual({ success: true, inserted: 3, updated: 1 }); + // The loader's per-record failure list is part of the shape, not extra + // baggage the schema drops on the floor. + const withErrors = PublishMetaItemResponseSchema.parse({ + ...realResponse, + seedApplied: { success: false, inserted: 0, updated: 0, errors: [{ row: 1, reason: 'bad ref' }] }, + }); + expect(withErrors.seedApplied?.errors).toHaveLength(1); + }); + + it('seedApplied counters are required integers once the key is present', () => { + expect( + PublishMetaItemResponseSchema.safeParse({ ...realResponse, seedApplied: { success: true } }).success, + ).toBe(false); + expect( + PublishMetaItemResponseSchema.safeParse({ + ...realResponse, seedApplied: { success: true, inserted: 1.5, updated: 0 }, + }).success, + ).toBe(false); + }); + + it('carries materializeApplied — present only when an ADR-0086 P2 materializer is registered', () => { + const parsed = PublishMetaItemResponseSchema.parse({ + ...realResponse, + materializeApplied: { success: false, inserted: 0, updated: 0, error: 'boom-from-materializer' }, + }); + expect(parsed.materializeApplied) + .toEqual({ success: false, inserted: 0, updated: 0, error: 'boom-from-materializer' }); + }); + + it('carries projectionApplied — the same ADR-0094 receipt the save door declares', () => { + const parsed = PublishMetaItemResponseSchema.parse({ + ...realResponse, + projectionApplied: { success: false, error: 'boom-from-projector' }, + }); + expect(parsed.projectionApplied).toEqual({ success: false, error: 'boom-from-projector' }); + expect( + PublishMetaItemResponseSchema.safeParse({ ...realResponse, projectionApplied: { error: 'x' } }).success, + ).toBe(false); + }); + + it('leaves all three receipts optional — absent means that side effect did not run', () => { + for (const key of ['seedApplied', 'materializeApplied', 'projectionApplied'] as const) { + expect(realResponse).not.toHaveProperty(key); + } + expect(PublishMetaItemResponseSchema.safeParse(realResponse).success).toBe(true); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 6a16e7220f..c0ed6a0700 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -489,6 +489,120 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ message: z.string().optional(), })); +/** + * Publish Metadata Item Response + * + * Describes the FULL body `POST /api/v1/meta/:type/:name/publish` returns + * (#7294 — the #5745 discipline carried one door over). The publish door is the + * sibling write surface of the save door: `saveMetaItem` writes a body, and + * `publishMetaItem` promotes an already-written DRAFT body to `active`. Until + * this declaration the route was served with no contract behind it at all — + * the string `PublishMetaItem` appeared nowhere under `packages/spec/src/`, so + * `version` here sat in exactly the undeclared state `version` on the save + * response sat in before #5745, despite being the same ADR-0008 + * optimistic-concurrency token with the same "echo it back as `If-Match`" job. + * + * Presence was measured against `origin/main`, not assumed. The sole producer + * is `ObjectStackProtocolImplementation.publishMetaItem`, which builds ONE + * response object and the REST route hands it to `res.json()` verbatim. That + * object literal always sets `success` / `version` / `seq`, so the three are + * REQUIRED; the three `*Applied` receipts are each attached only when the + * corresponding side effect ran, so each is optional — and their absence means + * "that side effect did not run", NEVER "it failed". + * + * **The three conditional receipts, and what makes each conditional** + * (`runPublishSideEffects`, phase 2 of the ADR-0067 D2 split): + * + * - `seedApplied` — only when the published type is `seed`. Publishing a seed + * is what makes its rows live, so the row materialization rides along. + * - `materializeApplied` — only when an ADR-0086 P2 publish materializer is + * registered for the type (e.g. `permission` → `sys_permission_set`). + * - `projectionApplied` — only when an ADR-0094 mutation projector is + * registered for the type. Same field, same meaning, as on + * {@link SaveMetaItemResponseSchema}: both write doors run the projector. + * + * All three are **best-effort by contract**: publishing the metadata always + * succeeds independently, and a side-effect failure is SURFACED here rather + * than thrown. So `success: true` on the envelope does not mean the data plane + * caught up — a caller that needs it live must read the receipt's own + * `success`, which is why every receipt carries one. + */ +export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ + success: z.boolean().describe( + 'Always true on a 2xx — the draft was promoted. It does NOT cover the ' + + 'best-effort side effects below, each of which reports its own `success`.', + ), + version: z.string().describe( + 'Content hash of the just-promoted body, and the token the ADR-0008 ' + + 'optimistic-concurrency chain runs on: send it back as the `If-Match` ' + + 'request header on the next write to that item and a concurrent edit is ' + + 'reported as 409 `metadata_conflict` instead of silently overwritten. ' + + 'Opaque to callers — echo it verbatim, never parse it. Currently emitted ' + + 'as `sha256:<64 hex chars>`, but the format is not part of this contract.', + ), + seq: z.number().int().describe( + 'Monotonic sequence number of the `op=\'publish\'` metadata event this ' + + 'promotion appended to the item history (sys_metadata_history.event_seq). ' + + 'Orders writes; unlike `version` it is not an OCC token.', + ), + seedApplied: z.object({ + success: z.boolean().describe( + 'False when the seed rows did not fully land. The publish itself still ' + + 'succeeded — check this rather than assuming data went live.', + ), + inserted: z.number().int().describe('Rows created by the externalId-keyed upsert.'), + updated: z.number().int().describe('Rows updated by the externalId-keyed upsert.'), + error: z.string().optional().describe( + 'Single failure message, present when the seed apply threw before the ' + + 'loader ran (including "no readable seed bodies").', + ), + errors: z.array(z.unknown()).optional().describe( + 'Per-record failures reported by the seed loader. Present only when the ' + + 'loader ran and returned a non-empty error list.', + ), + }).optional().describe( + 'Outcome of materializing a published `seed` body into data rows. Present ' + + 'ONLY when the published type is `seed` — publishing a seed is what makes ' + + 'its rows live, so the load rides along with the metadata promotion. ' + + 'Best-effort: a seed-load problem is surfaced here, never thrown, so a ' + + 'caller must check `seedApplied.success` instead of assuming the 200 ' + + 'covered the data. Absent on the batch path, which suppresses the ' + + 'per-item apply and loads every seed body in one later pass.', + ), + materializeApplied: z.object({ + success: z.boolean().describe('False when the materializer threw or reported failure; the publish still succeeded.'), + inserted: z.number().int().describe('Data-plane rows created by the materializer.'), + updated: z.number().int().describe('Data-plane rows updated by the materializer.'), + error: z.string().optional().describe('Materializer failure message, present only when `success` is false.'), + }).optional().describe( + 'Outcome of the ADR-0086 P2 publish-time materializer — the step that ' + + 'projects the published body into its data-plane row (e.g. `permission` ' + + '→ `sys_permission_set`, under the owning package). Present ONLY when a ' + + 'materializer is registered for this metadata type, which is why it is ' + + 'optional: its absence means "no materializer ran", never "it failed". ' + + 'Best-effort, same contract as `seedApplied`.', + ), + projectionApplied: z.object({ + success: z.boolean().describe('False when the projector threw; the metadata promotion itself still succeeded.'), + error: z.string().optional().describe('Projector failure message, present only when `success` is false.'), + }).optional().describe( + 'Outcome of the awaited ADR-0094 mutation projector — the post-persist step ' + + 'that materializes this metadata into its derived data-plane read model. ' + + 'The same receipt {@link SaveMetaItemResponseSchema} carries, because the ' + + 'projector runs on BOTH write doors: a direct active save and this ' + + 'draft→active promotion. Present ONLY when a projector is registered for ' + + 'this metadata type. Best-effort — a projector failure is reported here ' + + 'and logged, never thrown.', + ), + message: z.string().optional().describe( + 'Human-readable receipt, e.g. `Published draft — type=view, name=cases ' + + '[seq=3]`. The producer sets it on every publish today; it stays optional ' + + 'to match the producer\'s own signature and its `SaveMetaItemResponse` ' + + 'twin, and because an absent human-readable string strips no data — the ' + + 'failure mode #5745 exists to prevent.', + ), +})); + /** * Delete Metadata Item Request * Removes a customization overlay row from sys_metadata (ADR-0005). @@ -1612,6 +1726,7 @@ export type GetMetaItemResponse = z.input; export type GetMetaItemLayeredResponse = z.input; export type SaveMetaItemRequest = z.input; export type SaveMetaItemResponse = z.input; +export type PublishMetaItemResponse = z.input; export type DeleteMetaItemRequest = z.input; export type DeleteMetaItemResponse = z.input; export type GetMetaItemCachedRequest = z.input; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 9bd7a302bc..b12042a414 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -265,7 +265,7 @@ import type * as M167 from './ui/view.zod.js'; import type * as M170 from './ui/component.zod.js'; // --------------------------------------------------------------------------- -// 824 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 825 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 @@ -470,6 +470,7 @@ export type Iso134 = Assert, export type Iso833 = Assert, z.infer< typeof M28.GetMetaItemLayeredResponseSchema > >>; export type Iso135 = Assert, z.infer< typeof M28.SaveMetaItemRequestSchema > >>; export type Iso136 = Assert, z.infer< typeof M28.SaveMetaItemResponseSchema > >>; +export type Iso836 = Assert, z.infer< typeof M28.PublishMetaItemResponseSchema > >>; export type Iso137 = Assert, z.infer< typeof M28.DeleteMetaItemRequestSchema > >>; export type Iso138 = Assert, z.infer< typeof M28.DeleteMetaItemResponseSchema > >>; export type Iso139 = Assert, z.infer< typeof M28.GetMetaItemCachedRequestSchema > >>; @@ -1623,7 +1624,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 824 isomorphic pins', () => { + it('still declares all 825 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 @@ -1785,9 +1786,21 @@ describe('ADR-0122 type-alias convention', () => { // two shapes coincide and ADR-0122 gives it a pin rather than an // `XParsed`. The rename itself contributes 0: renaming a schema const // moves no shape, and this file counts pins, not names. + // + // 824 -> 825 is #7294's `PublishMetaItemResponseSchema` — the declaration + // that gives `POST /meta/:type/:name/publish` a response contract, the + // #5745 discipline one door over from `SaveMetaItemResponseSchema` + // (`Iso136`, two lines above the new pin). Isomorphism MEASURED, not + // assumed: the tree is booleans, strings, `z.number().int()`s, three + // inline optional objects and one `z.array(z.unknown())` — no + // `.default()`, `.transform()`, `.catch()` or `.pipe()` anywhere — so the + // two shapes coincide and ADR-0122 gives it a pin rather than an + // `XParsed`. Its id is `Iso836`, the next free one, not a number near its + // neighbours: the ids are claims about pins and not positions (the same + // rule the #4914 decrease and the #6604 entry above both record). const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert