diff --git a/.changeset/undeclared-response-shapes-sweep.md b/.changeset/undeclared-response-shapes-sweep.md new file mode 100644 index 0000000000..3e6b5bbdb0 --- /dev/null +++ b/.changeset/undeclared-response-shapes-sweep.md @@ -0,0 +1,54 @@ +--- +"@objectstack/spec": minor +"@objectstack/rest": minor +--- + +fix(spec,rest): three routes stop serving shapes their `responseSchema` never declared (#5882 #5950 #6442) + +Sweep #6487. One admission criterion: a route serves a response shape its +declared `responseSchema` does not describe. Three members, one direction each, +stated per member rather than picked for cheapness. + +**`GET /meta/:type/:name` — the ADR-0010 protection envelope is now declared +(#5950).** The uncached branch has always sent `lock` plus nine siblings on top +of `{ type, name, item }`, and `GetMetaItemResponseSchema` declared only the +three, so `.parse()` silently stripped every one of them. `lock` is the READ +half of the ADR-0008 optimistic-concurrency chain whose write half `#5745` +already declared — leaving it undeclared meant an SDK caller had to cast to read +it, the consumer-side tolerance Prime Directive #12 rejects. All ten keys are +declared **optional**, measured rather than assumed: the cached branch (the +default, `enableCache: true`) rebuilds the envelope as three keys and resolves +no lock at all, so `optional` here means "this branch did not publish it", never +"unlocked". Zero runtime change. Whether lock presence should depend on a cache +setting at all is the larger question #5950 raises and is deliberately left open. + +**`?layers=true` becomes `GET /meta/:type/:name/layers` (#5882).** The flag made +one route answer two unrelated resource representations — the ordinary envelope, +and a three-layer diagnostic projection (`code` / `overlay` / `effective`) that +drives Studio's "code default vs override vs effective" tabs — while the route +declared a single `responseSchema`. Anything generating a client from the route +table wrote a parser that was simply wrong for the flagged call. Per the +maintainer's ruling the projection gets its own path and its own +`GetMetaItemLayeredResponseSchema`: one path, one shape. The alternative — +teaching the route declaration to express "two shapes chosen by a query flag" — +was rejected as a new primitive every future tool would have to understand, and +conditional response selection is exactly where codegen and AI-written clients +go wrong. + +The `?layers=` spelling still answers the identical body during a deprecation +window (both entry points run one helper, so the two cannot drift), and now +carries `Deprecation: true` plus a `Link` header naming its successor. No +`Sunset` date: choosing the hard cut-off is a maintainer call. + +**`GET /analytics/meta` narrows to what it serves (#6442).** +`AnalyticsMetadataResponseSchema.data` declared `{ cubes: CubeSchema[] }` while +both implementations of `AnalyticsService.getMeta` return a bare `CubeMeta[]` +that the runtime hands to `success()` verbatim. A client written against the +published contract read `data.cubes` and got `undefined`; validating a live +response against the schema failed outright. Per the maintainer's ruling the +declaration narrows to the `CubeMeta[]` projection — zero runtime change — and +the generated `references/api/analytics.mdx`, which was publishing the wrong +shape, corrects itself. If a dashboard ever needs `format` or `description`, the +recorded return path is to add the key to the `CubeMeta` projection (additive); +widening the endpoint back to full cube definitions would push each cube's `sql` +to clients and is not revisited. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 5fb25f213f..b5a798132c 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -46,7 +46,7 @@ const result = AnalyticsEndpoint.parse(data); | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ cubes: object[] }` | ✅ | | +| **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. | --- diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index eecf05b587..4c6101e001 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, 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, 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, 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'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -699,6 +699,33 @@ Enable package response | **version** | `string` | optional | Metadata version identifier | +--- + +## GetMetaItemLayeredResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Metadata type name (canonical singular) | +| **name** | `string` | ✅ | Item name | +| **code** | `any` | ✅ | LAYER 1 — the packaged artifact baseline exactly as shipped, before any tenant customization. `null` when no artifact ships this item (it exists only as an overlay). | +| **overlay** | `any` | ✅ | LAYER 2 — the stored customization row ALONE, not merged with `code`. `null` when this tenant has not customized the item. | +| **overlayScope** | `Enum<'org' \| 'env'> \| null` | ✅ | Which scope the `overlay` row was read from — `org` for a tenant overlay, `env` for an environment-level one. `null` exactly when `overlay` is null. | +| **effective** | `any` | ✅ | LAYER 3 — the merged result, i.e. the value an ordinary `GET /meta/:type/:name` would return under `item`. `null` when the item resolves to nothing at all. | +| **_diagnostics** | `{ valid: boolean; errors?: object[]; warnings?: object[] }` | optional | Load-time spec-validation verdict for `effective`, so the Studio edit page can raise invalid-metadata banners and inline field errors without a second round trip. ABSENT for metadata types that register no Zod schema (function / service / router) — absence means "no opinion", never "valid". | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | ✅ | Resolved lock verdict (ADR-0010 §3.3), artifact winning over overlay. Always present on this path. | +| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | +| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | +| **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | +| **provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Where the item came from (package \| org \| env-forced). Present only when the resolved item declares `_provenance`. | +| **packageId** | `string` | optional | Owning package machine id. Present only when the resolved item declares `_packageId`. | +| **packageVersion** | `string` | optional | Owning package version. Present only when the resolved item declares `_packageVersion`. | +| **editable** | `boolean` | ✅ | Whether an overlay write is permitted. Always present on this path. | +| **deletable** | `boolean` | ✅ | Whether deleting the overlay is permitted. Always present on this path. | +| **resettable** | `boolean` | ✅ | Whether the item can be reset to its packaged default. Always present on this path. | + + --- ## GetMetaItemRequest @@ -723,6 +750,16 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | | **item** | `any` | ✅ | Metadata item definition | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` refuse the corresponding write with 403 `ITEM_LOCKED`. Resolved from the document's `_lock`, with the packaged artifact winning over any org overlay. | +| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | +| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | +| **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | +| **provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Where the item came from (package \| org \| env-forced). Present only when the resolved item declares `_provenance`. | +| **packageId** | `string` | optional | Owning package machine id. Present only when the resolved item declares `_packageId`. | +| **packageVersion** | `string` | optional | Owning package version. Present only when the resolved item declares `_packageVersion`. | +| **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. | +| **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | +| **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. | --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 94114bf1b9..5faafb095f 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 — 1583 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1584 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 | 409 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 28 | 410 | 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) | 29 | 164 | 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** | **199** | **1583** | 14 protocol modules | +| **Total** | **199** | **1584** | 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, 409 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **28 pages, 410 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`, `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`, `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 09d461a238..81aaa6d79c 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/` | 391 | +| `api/` | 393 | | `cloud/` | 83 | | `identity/` | 33 | | `integration/` | 10 | diff --git a/packages/rest/src/meta-item-layered-route.test.ts b/packages/rest/src/meta-item-layered-route.test.ts new file mode 100644 index 0000000000..8f4af1c47f --- /dev/null +++ b/packages/rest/src/meta-item-layered-route.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `GET /meta/:type/:name/layers` — the three-layer diagnostic projection as its + * own resource, and the deprecation window on the `?layers=true` spelling it + * replaces (#5882). + * + * ## What was wrong + * + * One route answered TWO unrelated resource representations, chosen by a query + * flag: the ordinary read's `{ type, name, item, … }` envelope, and — under + * `?layers=true` — a projection with the packaged baseline, the tenant overlay + * and the merged result side by side. `packages/spec` declared exactly one + * `responseSchema` for the route, so anything generating a client from the + * route table produced a parser that was simply wrong for the flagged call. + * + * The maintainer ruled (2026-08-06) for one path per response shape rather than + * teaching the route declaration to express "two shapes, chosen by a flag": + * conditional response selection is a new primitive every future tool would + * have to understand, and it is precisely where codegen and AI-written clients + * go wrong. + * + * ## What these tests hold down + * + * 1. the new path answers the layered projection, with the three layers SEPARATE + * (collapsing them would delete the diagnostic, which is the whole reason the + * shape exists); + * 2. the deprecated `?layers=` spelling still answers the IDENTICAL body during + * its window — a deprecation that silently changed the body would be a + * breakage wearing a deprecation label — and advertises its successor; + * 3. the ordinary read is undisturbed by either. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; +import { GetMetaItemLayeredResponseSchema, GetMetaItemResponseSchema } from '@objectstack/spec/api'; + +const ANON_API = { api: { requireAuth: false } }; + +const CUSTOMER = { name: 'customer', label: 'Customer', fields: { id: { type: 'text' } } }; + +/** The real `getMetaItemLayered` return shape, as `metadata-protocol` builds it. */ +const LAYERED = { + type: 'object', + name: 'customer', + code: { name: 'customer', label: 'Customer' }, + overlay: { label: 'Client' }, + overlayScope: 'org', + effective: { name: 'customer', label: 'Client' }, + _diagnostics: { valid: true }, + lock: 'none', + editable: true, + deletable: true, + resettable: false, +}; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const headers: Record = {}; + return { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + header: vi.fn((k: string, v: string) => { headers[k] = v; }), + send: vi.fn(), + headers, + }; +} + +function baseProtocol(overrides: Record = {}) { + return { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', + routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn(async ({ type, name }: any) => ({ + type, name, item: CUSTOMER, lock: 'none', editable: true, deletable: true, resettable: false, + })), + getMetaItemLayered: vi.fn(async ({ type, name }: any) => ({ ...LAYERED, type, name })), + getMetaItemCached: undefined as any, + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + ...overrides, + }; +} + +function routeFor(rest: RestServer, path: string) { + return (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path); +} + +async function dispatch(protocol: any, path: string, params: any, query: any = {}) { + const rest = new RestServer(mockServer() as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + const route = routeFor(rest, path); + if (!route) throw new Error(`route not registered: GET ${path}`); + const res = mockRes(); + await route.handler({ params, query, headers: {} }, res); + return { res, body: res.json.mock.calls.at(-1)?.[0] }; +} + +const LAYERS_PATH = '/api/v1/meta/:type/:name/layers'; +const ITEM_PATH = '/api/v1/meta/:type/:name'; + +describe('#5882 GET /meta/:type/:name/layers — the declared layered resource', () => { + it('is registered as its own route', async () => { + const rest = new RestServer(mockServer() as any, baseProtocol() as any, ANON_API as any); + rest.registerRoutes(); + expect(routeFor(rest, LAYERS_PATH)).toBeDefined(); + }); + + it('is registered BEFORE the routes that would otherwise capture its path', async () => { + // `/:type/:name` cannot match a 3-segment path, but + // `/:type/:section/:name` CAN — it would bind section=, + // name="layers" and answer an ordinary metadata read for an item called + // "layers". Under a first-match router, registration order is the only + // thing preventing that, so the order is the assertion. + const rest = new RestServer(mockServer() as any, baseProtocol() as any, ANON_API as any); + rest.registerRoutes(); + const paths = (rest as any).getRoutes() + .filter((r: any) => r.method === 'GET') + .map((r: any) => r.path); + const layers = paths.indexOf(LAYERS_PATH); + const compound = paths.indexOf('/api/v1/meta/:type/:section/:name'); + expect(layers).toBeGreaterThanOrEqual(0); + expect(compound).toBeGreaterThanOrEqual(0); + expect(layers).toBeLessThan(compound); + }); + + it('answers the three-layer projection, with the layers SEPARATE', async () => { + const protocol = baseProtocol(); + const { body } = await dispatch(protocol, LAYERS_PATH, { type: 'object', name: 'customer' }); + + expect(protocol.getMetaItemLayered).toHaveBeenCalled(); + // The diagnostic's substance: `code` is the packaged baseline, `overlay` + // the customization row ALONE, `effective` the merge — three distinct + // values, not one merged document. + expect(body.code).toEqual({ name: 'customer', label: 'Customer' }); + expect(body.overlay).toEqual({ label: 'Client' }); + expect(body.effective).toEqual({ name: 'customer', label: 'Client' }); + expect(body.overlayScope).toBe('org'); + expect(body.code).not.toEqual(body.effective); + // And it is NOT the ordinary envelope. + expect(body.item).toBeUndefined(); + }); + + it('answers a body that parses against its DECLARED schema', async () => { + // The point of the member: the shape on the wire and the shape + // `packages/spec` declares for this path are now the same object. + const { body } = await dispatch(baseProtocol(), LAYERS_PATH, { type: 'object', name: 'customer' }); + const parsed = GetMetaItemLayeredResponseSchema.safeParse(body); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.success).toBe(true); + }); + + it('threads `?package=` through for the package-scoped editor view (ADR-0048)', async () => { + const protocol = baseProtocol(); + await dispatch(protocol, LAYERS_PATH, { type: 'object', name: 'customer' }, { package: 'com.acme.crm' }); + expect(protocol.getMetaItemLayered).toHaveBeenCalledWith( + expect.objectContaining({ packageId: 'com.acme.crm' }), + ); + }); + + it('answers 501 when the protocol implements no layered view', async () => { + // A dedicated path must not fall through to the plain read the way the + // `?layers=` flag did — that would answer a different resource under a + // shape this path never declares. + const protocol = baseProtocol({ getMetaItemLayered: undefined }); + const { res, body } = await dispatch(protocol, LAYERS_PATH, { type: 'object', name: 'customer' }); + expect(res.status).toHaveBeenCalledWith(501); + expect(body.code).toBe('NOT_IMPLEMENTED'); + }); +}); + +describe('#5882 `?layers=true` — the deprecation window', () => { + it('still answers the layered body, byte for byte the same as the new path', async () => { + // The window's whole promise. Both entry points run ONE helper, and this + // is what would fail if someone re-forked them. + const viaFlag = await dispatch( + baseProtocol(), ITEM_PATH, { type: 'object', name: 'customer' }, { layers: 'true' }, + ); + const viaPath = await dispatch( + baseProtocol(), LAYERS_PATH, { type: 'object', name: 'customer' }, + ); + expect(viaFlag.body).toEqual(viaPath.body); + }); + + it('advertises the successor path in machine-readable headers', async () => { + const { res } = await dispatch( + baseProtocol(), ITEM_PATH, { type: 'object', name: 'customer' }, { layers: 'true' }, + ); + expect(res.header).toHaveBeenCalledWith('Deprecation', 'true'); + expect(res.headers.Link).toBe( + '; rel="successor-version"', + ); + }); + + it('does not mark the ordinary read deprecated', async () => { + const { res } = await dispatch(baseProtocol(), ITEM_PATH, { type: 'object', name: 'customer' }); + expect(res.header).not.toHaveBeenCalledWith('Deprecation', 'true'); + }); +}); + +describe('#5882 the ordinary read is undisturbed', () => { + it('answers the `{ type, name, item }` envelope with its protection keys', async () => { + const protocol = baseProtocol(); + const { body } = await dispatch(protocol, ITEM_PATH, { type: 'object', name: 'customer' }); + + expect(protocol.getMetaItemLayered).not.toHaveBeenCalled(); + expect(body).toMatchObject({ type: 'object', name: 'customer', lock: 'none', editable: true }); + expect(body.item).toMatchObject({ name: 'customer', label: 'Customer' }); + // No layer leaked into the ordinary envelope. + expect(body.code).toBeUndefined(); + expect(body.overlay).toBeUndefined(); + expect(body.effective).toBeUndefined(); + }); + + it('answers a body that parses against `GetMetaItemResponseSchema` on BOTH branches (#5950)', async () => { + // The end-to-end half of #5950, and what entitles this route's ledger + // row to name a `responseSchema`: the declaration is measured against + // the real mount, on both branches that reach a body, rather than + // against a schema-level fixture that no handler produced. + + // Uncached: carries the full ADR-0010 protection envelope. + const uncached = await dispatch(baseProtocol(), ITEM_PATH, { type: 'object', name: 'customer' }); + const uncachedParse = GetMetaItemResponseSchema.safeParse(uncached.body); + expect(uncachedParse.error?.issues ?? []).toEqual([]); + // The carriers survive the parse — the point of the member. Before the + // declaration widened, `.parse()` dropped every one of them. + expect((uncachedParse.data as any).lock).toBe('none'); + expect((uncachedParse.data as any).editable).toBe(true); + + // Cached (THE DEFAULT): three keys, no lock resolved — still valid, + // which is why the envelope is declared optional rather than required. + const cachedProtocol = baseProtocol({ + getMetaItemCached: vi.fn().mockResolvedValue({ + data: CUSTOMER, etag: { value: 'abc', weak: false }, notModified: false, + }), + }); + const cached = await dispatch(cachedProtocol, ITEM_PATH, { type: 'object', name: 'customer' }); + expect(cachedProtocol.getMetaItemCached).toHaveBeenCalled(); + const cachedParse = GetMetaItemResponseSchema.safeParse(cached.body); + expect(cachedParse.error?.issues ?? []).toEqual([]); + expect((cachedParse.data as any).lock).toBeUndefined(); + }); + + it('treats `?layers=` with an empty value as NOT a layered request', async () => { + // Pre-existing semantics, pinned so the new route does not quietly + // change which requests are layered. + const protocol = baseProtocol(); + const { body } = await dispatch(protocol, ITEM_PATH, { type: 'object', name: 'customer' }, { layers: '' }); + expect(protocol.getMetaItemLayered).not.toHaveBeenCalled(); + expect(body.item).toMatchObject({ label: 'Customer' }); + }); +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index eafa9189d7..8256b9413c 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -136,7 +136,27 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/meta/:type', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItems' }, { route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getReferences' }, { route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getBookTree' }, - { route: 'GET /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem' }, + // [#5882] The three-layer diagnostic projection, promoted from the + // `?layers=true` flag on the row below to a path of its own so that one path + // answers one response shape. `responseSchema` is filled because this mount + // HAS conformance coverage of its own: `meta-item-layered-route.test.ts` + // drives this handler and parses the body it answers against the named + // schema — not "same handler, therefore same shape". + // + // `server-only`, and NOT `gap`: the gap ratchet is pinned at zero and a new + // `gap` row is defined to need its own reviewed decision, which this PR does + // not carry. The disposition is accurate on its own terms — the SDK has never + // expressed a layered read, the `?layers=` spelling this path replaces was + // equally unreachable through `@objectstack/client`, and Studio consumes it + // straight over HTTP. So this row opens no gap and closes none; it records the + // status quo under a new path. Whether the SDK SHOULD express it is a separate + // product call. + { route: 'GET /api/v1/meta/:type/:name/layers', family: 'metadata', source: 'route-manager', disposition: 'server-only', + responseSchema: 'GetMetaItemLayeredResponseSchema', + note: 'three-layer diagnostic read (code / overlay / effective) powering the Studio editor comparison tabs; consumed by objectui over plain HTTP, and the SDK expressed no layered read under the `?layers=` spelling either. Answers BARE, so the named schema is the whole body' }, + { route: 'GET /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem', + responseSchema: 'GetMetaItemResponseSchema', + note: '[#5950] answers BARE, so the named schema is the whole body. Filled now that meta-item-layered-route.test.ts parses BOTH branches of this mount (cached and uncached) against it — the uncached branch carries the ADR-0010 protection envelope this schema newly declares' }, { route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem' }, { route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem', note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path' }, diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 302a3f9c8d..48d9e9ff2e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2904,6 +2904,59 @@ export class RestServer { }; } + /** + * Serve the three-layer diagnostic projection (`code` / `overlay` / + * `effective`) declared by `GetMetaItemLayeredResponseSchema`. + * + * ONE implementation behind TWO entry points (#5882): the canonical + * `GET /meta/:type/:name/layers`, and the deprecated + * `GET /meta/:type/:name?layers=true` it replaces. Extracted rather than + * duplicated precisely because the deprecation window's promise is that the + * old spelling answers *the same body* — two copies would let that stop + * being true without anything failing. + * + * Not translated and not cached, both deliberately: this is a diagnostic + * view of what is STORED at each layer, so locale-collapsing it (or serving + * it from the published-value cache) would misreport the thing being + * diagnosed. + */ + private async serveMetaItemLayered( + req: any, + res: any, + environmentId: string | undefined, + p: any, + maskPosture: ObjectSchemaMaskPosture, + ): Promise { + // ADR-0048 — thread `?package=` so the layered (Studio editor) view is + // package-scoped; the editor passes the edited item's owning package, + // not the studio app's. + const layeredPackageId = req.query?.package || undefined; + const layered = await p.getMetaItemLayered({ + type: req.params.type, + name: req.params.name, + ...(layeredPackageId ? { packageId: layeredPackageId } : {}), + ...(environmentId ? { environmentId } : {}), + }); + // [ADR-0106 D5(4)] The layered view is a schema-bearing exit — + // `code`, `overlay` and `effective` are each a full object schema. + // Both entry points (the canonical `/layers` path and the deprecated + // `?layers=` flag) pass their request's resolved posture in, so the + // extraction cannot turn the mask into a one-entry-point detour. + if (maskPosture.kind === 'project') { + for (const layer of ['code', 'overlay', 'effective'] as const) { + const masked = this.maskObjectDocument( + res, maskPosture, req.params.name, (layered as any)?.[layer], + ); + if (!masked) return; + if (layered && typeof layered === 'object') (layered as any)[layer] = masked.document; + } + } + if (maskPosture.kind === 'undetermined') { + res.header('Cache-Control', 'private, no-store'); + } + res.json(layered); + } + /** * [ADR-0106 D2/D4/D6/D7/D8] Build this request's object-schema masker — a * per-object-name posture resolver whose caller context and `security` @@ -4354,6 +4407,67 @@ export class RestServer { }, }); + // [#5882] GET /meta/:type/:name/layers — the three-layer diagnostic + // projection as its OWN resource. Registered BEFORE + // /meta/:type/:name for the same first-match reason as + // /references above, and before /meta/:type/:section/:name, which + // would otherwise capture this path with section=, + // name="layers". + // + // This path exists because the projection used to be reachable only + // as `GET /meta/:type/:name?layers=true` — the same route answering + // a SECOND, undeclared body shape depending on a query flag, while + // `packages/spec` declared one `responseSchema` for it. The ruled + // fix (maintainer, 2026-08-06) was one path per response shape, + // deliberately NOT teaching the route declaration to express + // "two shapes chosen by a flag": that would add a primitive every + // future tool has to understand, and conditional response selection + // is exactly where codegen and AI-written clients go wrong. + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/:type/:name/layers`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + // [ADR-0106 D2/D5] The dedicated path is its own + // schema-serving outlet — it resolves the caller's + // field-visibility posture exactly like the plain meta + // read does, with the NORMALIZED type (#3984 / #6241). + const layeredMetaType = RestServer.metaTypeSingular(req.params.type); + let maskPosture: ObjectSchemaMaskPosture; + try { + maskPosture = await (await this.resolveObjectMasker(environmentId, req, layeredMetaType))(req.params.name); + } catch (maskError: any) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) { + sendFieldVisibilityFault(res, req.params.name); + return; + } + throw maskError; + } + if (typeof (p as any).getMetaItemLayered !== 'function') { + // A dedicated path cannot fall through to the plain + // read the way the `?layers=` flag did — answering + // the merged `{ type, name, item }` envelope here + // would be answering a different resource with a + // shape this path never declares. + res.status(501).json({ + error: 'Layered metadata view not supported by protocol implementation', + code: 'NOT_IMPLEMENTED', + }); + return; + } + await this.serveMetaItemLayered(req, res, environmentId, p, maskPosture); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Get a metadata item as its three layers (code / overlay / effective)', + tags: ['metadata'], + }, + }); + // ADR-0046 §6 — GET /meta/book/:name/tree // Resolve a book spine against the docs that exist *now* into a // rendered tree (membership is DERIVED, never stored — §6.2.1). An @@ -4510,52 +4624,42 @@ export class RestServer { // Skips the cache path entirely — layered view is a // diagnostic endpoint, not on the hot read path. // - // [#5563] This is a DIFFERENT RESOURCE reached through a - // query flag, not a variant body of the same one: it - // answers `{ type, name, code, overlay, overlayScope, - // effective, validation }` — three layers side by side, - // where `effective` is what the plain read would return. - // Collapsing it into `GetMetaItemResponseSchema`'s single - // `item` would delete the diagnostic (the whole point is - // seeing code vs overlay vs effective separately), so the - // convergence deliberately stops at the ordinary read. - // What remains is a spec gap, not a runtime split: the - // route declares ONE `responseSchema` while `?layers=` - // answers a second, undeclared shape — filed as #5882 - // for a spec seat. + // [#5563 → #5882] DEPRECATED SPELLING. This flag makes one + // route answer a SECOND resource representation — three + // layers side by side (`code` / `overlay` / `effective`), + // where `effective` is what the plain read returns — while + // the route declares a single `responseSchema`. #5563 + // converged the ordinary read and left this half open + // because collapsing the layers into + // `GetMetaItemResponseSchema`'s single `item` would delete + // the diagnostic outright. + // + // #5882 closed it the other way (maintainer ruling, 2026-08-06): + // the projection is now its own path, + // `GET /meta/:type/:name/layers`, declared by + // `GetMetaItemLayeredResponseSchema`. One path, one shape. + // + // This branch stays for a deprecation window so existing + // callers (Studio's metadata editor) are not broken by the + // move. It answers the IDENTICAL body — same helper, not a + // copy — and advertises the successor in the response + // headers, so a client can discover the migration without + // reading the changelog. Delete this branch (and the + // headers with it) once the callers have moved. const wantLayered = req.query?.layers !== undefined && req.query?.layers !== ''; if (wantLayered && typeof (p as any).getMetaItemLayered === 'function') { - // ADR-0048 — thread `?package=` so the layered (Studio - // editor) view is package-scoped; the editor passes the - // edited item's owning package, not the studio app's. - const layeredPackageId = req.query?.package || undefined; - const layered = await (p as any).getMetaItemLayered({ - type: req.params.type, - name: req.params.name, - ...(layeredPackageId ? { packageId: layeredPackageId } : {}), - ...(environmentId ? { environmentId } : {}), - }); - // [ADR-0106 D5(4)] The layered view is a THIRD - // schema-bearing exit on this route, reached by a - // query flag — `code`, `overlay` and `effective` are - // each a full object schema. Leaving it unprojected - // would have made the mask a one-query-param detour, - // which is precisely the "or the mask is decoration" - // case D5 names. Its usual caller (the Studio - // editor) is exempt under D4 and sees no change. - if (maskPosture.kind === 'project') { - for (const layer of ['code', 'overlay', 'effective'] as const) { - const masked = this.maskObjectDocument( - res, maskPosture, req.params.name, (layered as any)?.[layer], - ); - if (!masked) return; - if (layered && typeof layered === 'object') (layered as any)[layer] = masked.document; - } - } - if (maskPosture.kind === 'undetermined') { - res.header('Cache-Control', 'private, no-store'); - } - res.json(layered); + // RFC 9745 `Deprecation` + RFC 8288 `Link` — the same + // machine-readable pairing `versioning.zod.ts` already + // describes for retiring API versions, applied to a + // retiring query flag. No `Sunset` date: choosing the + // hard cut-off is a maintainer call, and an invented + // date is worse than none. + res.header('Deprecation', 'true'); + res.header( + 'Link', + `<${metaPath}/${req.params.type}/${req.params.name}/layers>; rel="successor-version"`, + ); + await this.serveMetaItemLayered(req, res, environmentId, p, maskPosture); return; } diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 1061f635c5..54d92315d5 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -414,6 +414,8 @@ "GetMetaItemCachedResponse (type)", "GetMetaItemCachedResponseParsed (type)", "GetMetaItemCachedResponseSchema (const)", + "GetMetaItemLayeredResponse (type)", + "GetMetaItemLayeredResponseSchema (const)", "GetMetaItemRequest (type)", "GetMetaItemRequestSchema (const)", "GetMetaItemResponse (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 0d5c357a9e..9f4087270b 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -711,11 +711,38 @@ "api/GetMetaItemCachedResponse:lastModified", "api/GetMetaItemCachedResponse:notModified", "api/GetMetaItemCachedResponse:version", + "api/GetMetaItemLayeredResponse:_diagnostics", + "api/GetMetaItemLayeredResponse:code", + "api/GetMetaItemLayeredResponse:deletable", + "api/GetMetaItemLayeredResponse:editable", + "api/GetMetaItemLayeredResponse:effective", + "api/GetMetaItemLayeredResponse:lock", + "api/GetMetaItemLayeredResponse:lockDocsUrl", + "api/GetMetaItemLayeredResponse:lockReason", + "api/GetMetaItemLayeredResponse:lockSource", + "api/GetMetaItemLayeredResponse:name", + "api/GetMetaItemLayeredResponse:overlay", + "api/GetMetaItemLayeredResponse:overlayScope", + "api/GetMetaItemLayeredResponse:packageId", + "api/GetMetaItemLayeredResponse:packageVersion", + "api/GetMetaItemLayeredResponse:provenance", + "api/GetMetaItemLayeredResponse:resettable", + "api/GetMetaItemLayeredResponse:type", "api/GetMetaItemRequest:name", "api/GetMetaItemRequest:packageId", "api/GetMetaItemRequest:type", + "api/GetMetaItemResponse:deletable", + "api/GetMetaItemResponse:editable", "api/GetMetaItemResponse:item", + "api/GetMetaItemResponse:lock", + "api/GetMetaItemResponse:lockDocsUrl", + "api/GetMetaItemResponse:lockReason", + "api/GetMetaItemResponse:lockSource", "api/GetMetaItemResponse:name", + "api/GetMetaItemResponse:packageId", + "api/GetMetaItemResponse:packageVersion", + "api/GetMetaItemResponse:provenance", + "api/GetMetaItemResponse:resettable", "api/GetMetaItemResponse:type", "api/GetMetaItemsRequest:packageId", "api/GetMetaItemsRequest:type", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 0dda14515f..83e3e373ee 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -174,6 +174,7 @@ "api/GetLocalesResponse", "api/GetMetaItemCachedRequest", "api/GetMetaItemCachedResponse", + "api/GetMetaItemLayeredResponse", "api/GetMetaItemRequest", "api/GetMetaItemResponse", "api/GetMetaItemsRequest", diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index e7660206fe..49e4a8a9ab 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -7,6 +7,28 @@ import { AnalyticsMetadataResponseSchema, AnalyticsSqlResponseSchema, } from './analytics.zod'; +import type { AnalyticsMetadataResponse } from './analytics.zod'; +import type { CubeMeta } from '../contracts/analytics-service'; + +/** Type-level identity: true iff A and B are the same type. */ +type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +/** Compile error when the argument is not `true`. */ +type Assert< T extends true > = T; + +/** + * #6442 — the declared element of `AnalyticsMetadataResponse.data` IS the + * `CubeMeta` contract interface, not merely shaped like it. + * + * `CubeMeta` in `contracts/analytics-service.ts` is what both `getMeta` + * implementations return, and it was already correct while + * `AnalyticsMetadataResponseSchema` was not — `packages/spec` stated one shape + * in two files and let them disagree. Binding them here is what stops that + * happening a second time: narrow either one alone and this goes red. + * + * Exported deliberately — an unread alias inside a test body is TS6196, and a + * `@ts-expect-error`-style pin no program compiles is no pin at all. + */ +export type CubeMetaMatchesContract = Assert< Eq< AnalyticsMetadataResponse['data'][number], CubeMeta > >; describe('AnalyticsEndpoint', () => { it('should accept all valid endpoints', () => { @@ -156,55 +178,104 @@ describe('GetAnalyticsMetaRequestSchema', () => { }); }); -describe('AnalyticsMetadataResponseSchema', () => { - it('should accept valid metadata response', () => { - const resp = AnalyticsMetadataResponseSchema.parse({ - success: true, - data: { - cubes: [ - { - name: 'orders', - sql: 'SELECT * FROM orders', - measures: { - total_revenue: { - name: 'total_revenue', - label: 'Total Revenue', - type: 'sum', - sql: 'amount', - }, - }, - dimensions: { - status: { - name: 'status', - label: 'Status', - type: 'string', - sql: 'status', - }, - }, - }, +/** + * #6442 — `data` is the `CubeMeta[]` discovery projection the endpoint really + * serves, NOT the `{ cubes: CubeSchema[] }` wrapper it used to declare. + * + * The three cases here previously pinned the old declaration, and every one of + * them was re-judged rather than re-spelled: the first pinned a body no + * implementation has ever produced (replaced with the real one), the second only + * needed the wrapper dropped, and the third — "should reject missing cubes" — + * would have kept passing against the new schema for the WRONG reason (`{}` is + * not an array either), pinning a `cubes` key that no longer exists. It is + * replaced by the assertion that carries the actual load of this change: the + * previously-declared shape is now rejected. + */ +describe('AnalyticsMetadataResponseSchema — the CubeMeta[] projection (#6442)', () => { + /** + * A real `GET /analytics/meta` body: what `AnalyticsService.getMeta` and its + * `driver-memory` twin both build — measure/dimension names CUBE-QUALIFIED, + * `title` projected from the definition's `label`, and no `sql` anywhere. + */ + const SERVED_BODY = { + success: true, + data: [ + { + name: 'orders', + title: 'Orders', + measures: [ + { name: 'orders.total_revenue', type: 'sum', title: 'Total Revenue' }, + ], + dimensions: [ + { name: 'orders.status', type: 'string', title: 'Status' }, ], }, - }); - expect(resp.data.cubes).toHaveLength(1); - expect(resp.data.cubes[0].name).toBe('orders'); + ], + }; + + it('accepts the body the endpoint actually serves', () => { + const resp = AnalyticsMetadataResponseSchema.parse(SERVED_BODY); + expect(resp.data).toHaveLength(1); + expect(resp.data[0].name).toBe('orders'); + // The substance of the projection: qualified member names, display titles, + // and no `sql` reaching the client. + expect(resp.data[0].measures[0].name).toBe('orders.total_revenue'); + expect(resp.data[0].measures[0].title).toBe('Total Revenue'); + expect(resp.data[0].dimensions[0].name).toBe('orders.status'); + expect(resp.data[0]).not.toHaveProperty('sql'); }); - it('should accept empty cubes list', () => { - const resp = AnalyticsMetadataResponseSchema.parse({ - success: true, - data: { cubes: [] }, - }); - expect(resp.data.cubes).toHaveLength(0); + it('accepts an empty cube list', () => { + const resp = AnalyticsMetadataResponseSchema.parse({ success: true, data: [] }); + expect(resp.data).toHaveLength(0); }); - it('should reject missing cubes', () => { + it('REJECTS the previously-declared `{ cubes: CubeSchema[] }` wrapper', () => { + // This is the direction of the fix, stated as a test: the old declaration + // described a body no implementation produced, so a client written against + // it read `data.cubes` and got `undefined`. That shape must now fail loudly + // rather than be quietly accepted alongside the real one. expect(() => AnalyticsMetadataResponseSchema.parse({ success: true, - data: {}, - }) + data: { + cubes: [ + { + name: 'orders', + sql: 'SELECT * FROM orders', + measures: { total_revenue: { name: 'total_revenue', label: 'Total Revenue', type: 'sum', sql: 'amount' } }, + dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + }, + ], + }, + }), + ).toThrow(); + }); + + it('requires each measure/dimension to carry `name` and `type`', () => { + expect(() => + AnalyticsMetadataResponseSchema.parse({ + success: true, + data: [{ name: 'orders', measures: [{ title: 'Total Revenue' }], dimensions: [] }], + }), ).toThrow(); }); + + it('declares exactly the `CubeMeta` contract shape, element for element', () => { + // The compile-time half is `CubeMetaMatchesContract` at module scope below — + // it must be EXPORTED to be a real check: a type alias declared inside this + // function body and never read is TS6196 under `noUnusedLocals`, and + // deleting it would leave every gate just as green (the phantom-check shape + // AGENTS.md names). + // + // Runtime half: a value typed as the contract parses against the schema. + const fromContract: CubeMeta = { + name: 'orders', + measures: [{ name: 'orders.total_revenue', type: 'sum' }], + dimensions: [{ name: 'orders.status', type: 'string' }], + }; + expect(() => AnalyticsMetadataResponseSchema.parse({ success: true, data: [fromContract] })).not.toThrow(); + }); }); describe('AnalyticsSqlResponseSchema', () => { diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index 251d731098..d98fbc17f7 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { AnalyticsQuerySchema, CubeSchema } from '../data/analytics.zod'; +import { AnalyticsQuerySchema } from '../data/analytics.zod'; import { BaseResponseSchema } from './contract.zod'; import { retiredKey } from '../shared/retired-key'; @@ -87,14 +87,74 @@ export const GetAnalyticsMetaRequestSchema = lazySchema(() => z.object({ cube: z.string().optional().describe('Optional cube name to filter'), })); +/** + * A measure or dimension as `GET /analytics/meta` publishes it — the discovery + * projection, not the authoring definition. + * + * `name` is CUBE-QUALIFIED (`"."`), which is the form + * `/analytics/query` expects back in `measures[]` / `dimensions[]`; the + * unqualified key it was defined under is not published. `title` carries the + * definition's `label`, so it is the display name a dashboard renders. + * + * Deliberately narrower than the authoring definitions (`MetricSchema` / + * `DimensionSchema` in `data/analytics.zod.ts`): `sql`, `filters`, + * `description`, `granularities` and `format` are dropped by the projection and + * are NOT reachable through this endpoint (#6442). + * + * Module-local, and NOT exported as its own named schema: `CubeMeta` in + * `contracts/analytics-service.ts` is already THE name for this shape, so a + * second exported name would be the permanent synonym ADR-0122 D3 forbids AND a + * new dual-source export. `analytics.test.ts` binds the two at compile time. + */ +const cubeMetaMemberShape = () => z.object({ + name: z.string().describe('Cube-qualified member name, `"."` — the spelling `/analytics/query` accepts'), + type: z.string().describe( + 'Aggregation type for a measure (`AggregationMetricType`) or data type for a ' + + 'dimension (`DimensionType`). Declared as a string rather than either enum ' + + 'because the projection copies the value through verbatim and this one ' + + 'shape serves both member kinds.', + ), + title: z.string().optional().describe('Display label, projected from the definition\'s `label`'), +}); + /** * Meta Response - * Returns available cubes, metrics, and dimensions. + * + * Describes the body `GET /api/v1/analytics/meta` actually returns: `data` is a + * BARE ARRAY of the `CubeMeta` discovery projection — not `{ cubes: Cube[] }` + * (#6442, ruled by the maintainer 2026-08-08 as "narrow the declaration"). + * + * The previous declaration described a shape the endpoint has never served, in + * either implementation: `AnalyticsService.getMeta` + * (`service-analytics/src/analytics-service.ts`) and its `driver-memory` twin + * (`memory-analytics.ts`) both answer `Promise< CubeMeta[] >`, and + * `runtime/src/domains/analytics.ts` hands that array to `success()` verbatim, + * so it lands directly under `data`. A client written against the old + * declaration read `data.cubes` and got `undefined`; a client that validated a + * live response against this schema failed outright. `packages/spec` stated + * both shapes itself — the TS contract + * (`contracts/analytics-service.ts`, `getMeta(): Promise< CubeMeta[] >`) already + * agreed with the runtime, and this schema was the lone outlier. + * + * Zero runtime change: the wire body is untouched, only its declaration moves. + * + * **Return path if more keys are ever needed** (recorded with the ruling): add + * the key to the projection above — additive and backwards compatible. ⛔ Do NOT + * widen this endpoint back to full `CubeSchema` definitions: that would publish + * each cube's `sql` to every client, a capability expansion with no measured + * consumer pulling it. */ export const AnalyticsMetadataResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - cubes: z.array(CubeSchema).describe('Available cubes'), - }), + data: z.array(z.object({ + name: z.string().describe('Cube name'), + title: z.string().optional().describe('Human-readable cube title'), + measures: z.array(cubeMetaMemberShape()).describe('Measures this cube accepts in `/analytics/query`'), + dimensions: z.array(cubeMetaMemberShape()).describe('Dimensions this cube accepts in `/analytics/query`'), + })).describe( + 'Available cubes, each as the `CubeMeta` discovery projection — the cube name, ' + + 'its title, and the measures/dimensions a client may name in a query. A bare ' + + 'array: there is no `cubes` wrapper object, and no cube `sql` is published.', + ), })); // ========================================== diff --git a/packages/spec/src/api/meta-item-response-shapes.test.ts b/packages/spec/src/api/meta-item-response-shapes.test.ts new file mode 100644 index 0000000000..ae53a6cf69 --- /dev/null +++ b/packages/spec/src/api/meta-item-response-shapes.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two response shapes `GET /api/v1/meta/:type/:name…` serves, each declared + * by its own schema (sweep #6487). + * + * Both members here are DECLARATION changes with zero runtime effect, so the + * assertions have to bear the load themselves: it is not enough that the new + * keys exist in the file, they must be reachable at the type level (the thing a + * consumer was previously forced to `cast` for) and the real wire bodies must + * parse against them. + * + * - **#5950** — the uncached branch of `GET /meta/:type/:name` has always sent + * the ADR-0010 protection envelope (`lock` and nine siblings) on top of + * `{ type, name, item }`, and `GetMetaItemResponseSchema` declared only the + * three. `lock` is the READ half of the ADR-0008 optimistic-concurrency chain + * whose write half `SaveMetaItemResponseSchema` already declares (#5745). + * - **#5882** — `?layers=true` answered a completely different projection that + * the route never declared. It now has its own path and its own schema. + */ + +import { describe, it, expect } from 'vitest'; +import { + GetMetaItemResponseSchema, + GetMetaItemLayeredResponseSchema, +} from './protocol.zod'; +import type { GetMetaItemResponse, GetMetaItemLayeredResponse } from './protocol.zod'; +import type { MetadataLock } from '../kernel/metadata-protection.zod'; + +/** Type-level identity / assignability helpers. */ +type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Assert< T extends true > = T; + +/** + * The compile-time half of both members, at module scope and EXPORTED. + * + * These have to live here rather than inside an `it()` body for two reasons + * that both bite: an unread alias in a function body is TS6196 under + * `noUnusedLocals`, and — the one that matters — a pin no program compiles is a + * phantom check that stays green after someone deletes the thing it guards. + * `packages/spec` compiles its tests via `tsconfig.test.json`, so these are + * real: reverting either member turns them red at `pnpm typecheck`. + */ + +/** + * #5950 — `lock` is reachable AND typed as the ADR-0010 union. This is the + * #5545 complaint stated as a compile-time fact: an SDK caller holding a + * `GetMetaItemResponse` can branch on the lock's VALUES without a cast, which + * is what "the read side has a contract" has to mean. + */ +export type LockIsTheAdr0010Union = Assert< Eq< GetMetaItemResponse['lock'], MetadataLock | undefined > >; + +/** + * #5882 — the two responses are genuinely different resources: neither schema + * is a widening of the other, which is the fact that justifies a second path + * instead of one stretched declaration. + */ +export type LayeredCarriesNoItem = Assert< Eq< 'item' extends keyof GetMetaItemLayeredResponse ? true : false, false > >; +export type PlainCarriesNoLayers = Assert< Eq< 'effective' extends keyof GetMetaItemResponse ? true : false, false > >; + +/** The document under `item` — irrelevant to these shapes, so kept trivial. */ +const CUSTOMER = { name: 'customer', label: 'Customer' }; + +describe('#5950 GetMetaItemResponseSchema — the ADR-0010 protection envelope is declared', () => { + /** + * The uncached branch's real body: `metadata-protocol`'s `getMetaItem` + * return, spread onto the wire verbatim by `translateMetaEnvelope`. + * All ten protection keys, including the six conditional ones. + */ + const UNCACHED_BODY = { + type: 'object', + name: 'customer', + item: CUSTOMER, + lock: 'no-overlay', + lockReason: 'Shipped by the setup package', + lockSource: 'package', + lockDocsUrl: 'https://docs.example.test/locks', + provenance: 'package', + packageId: 'com.objectstack.setup', + packageVersion: '1.2.3', + editable: false, + deletable: true, + resettable: true, + }; + + it('parses the uncached body WITHOUT stripping the protection envelope', () => { + const parsed = GetMetaItemResponseSchema.parse(UNCACHED_BODY); + // The substance: every carrier survives the parse. Before this change + // `.parse()` silently dropped all ten — the same way the write side + // dropped `version` before #5745. + expect(parsed.lock).toBe('no-overlay'); + expect(parsed.lockReason).toBe('Shipped by the setup package'); + expect(parsed.lockSource).toBe('package'); + expect(parsed.lockDocsUrl).toBe('https://docs.example.test/locks'); + expect(parsed.provenance).toBe('package'); + expect(parsed.packageId).toBe('com.objectstack.setup'); + expect(parsed.packageVersion).toBe('1.2.3'); + expect(parsed.editable).toBe(false); + expect(parsed.deletable).toBe(true); + expect(parsed.resettable).toBe(true); + }); + + it('still parses the CACHED body, where every protection key is absent', () => { + // The default deployment (`enableCache: true`) rebuilds the envelope as + // exactly these three keys and resolves no lock. Declaring the envelope + // REQUIRED would have made the default path fail its own contract. + const parsed = GetMetaItemResponseSchema.parse({ type: 'object', name: 'customer', item: CUSTOMER }); + expect(parsed.type).toBe('object'); + expect(parsed.item).toEqual(CUSTOMER); + expect(parsed.lock).toBeUndefined(); + expect(parsed.editable).toBeUndefined(); + }); + + it('rejects a lock value outside the ADR-0010 vocabulary', () => { + // The declaration is not merely wider — it is the lock vocabulary. A value + // the resolver could never produce must not validate. + expect(() => + GetMetaItemResponseSchema.parse({ ...UNCACHED_BODY, lock: 'read-only' }), + ).toThrow(); + expect(() => + GetMetaItemResponseSchema.parse({ ...UNCACHED_BODY, lockSource: 'overlay' }), + ).toThrow(); + }); + + it('types `lock` as the ADR-0010 union — not `unknown`, so no cast is needed', () => { + // Compile-time half: `LockIsTheAdr0010Union` at module scope. Runtime half: + // a response literal that declares `lock` and narrows on it, with no cast. + const response: GetMetaItemResponse = { type: 'object', name: 'customer', item: CUSTOMER, lock: 'full' }; + // Reading it needs no cast; narrowing works. + const readOnly = response.lock === 'full' || response.lock === 'no-overlay'; + expect(readOnly).toBe(true); + }); +}); + +describe('#5882 GetMetaItemLayeredResponseSchema — the three-layer projection', () => { + /** What `getMetaItemLayered` returns, and `GET /meta/:type/:name/layers` serves. */ + const LAYERED_BODY = { + type: 'object', + name: 'customer', + code: { name: 'customer', label: 'Customer' }, + overlay: { label: 'Client' }, + overlayScope: 'org', + effective: { name: 'customer', label: 'Client' }, + _diagnostics: { valid: true }, + lock: 'none', + editable: true, + deletable: true, + resettable: false, + }; + + it('parses the layered body with all three layers intact', () => { + const parsed = GetMetaItemLayeredResponseSchema.parse(LAYERED_BODY); + // The whole point of the projection: the layers stay SEPARATE. Collapsing + // them into one `item` is what the ordinary read does, and doing it here + // would delete the diagnostic. + expect(parsed.code).toEqual({ name: 'customer', label: 'Customer' }); + expect(parsed.overlay).toEqual({ label: 'Client' }); + expect(parsed.effective).toEqual({ name: 'customer', label: 'Client' }); + expect(parsed.overlayScope).toBe('org'); + expect(parsed._diagnostics).toEqual({ valid: true }); + }); + + it('accepts an uncustomized item — null overlay, null scope', () => { + const parsed = GetMetaItemLayeredResponseSchema.parse({ + ...LAYERED_BODY, + overlay: null, + overlayScope: null, + }); + expect(parsed.overlay).toBeNull(); + expect(parsed.overlayScope).toBeNull(); + }); + + it('accepts an absent `_diagnostics` — types with no registered Zod schema', () => { + const { _diagnostics: _omitted, ...withoutDiagnostics } = LAYERED_BODY; + const parsed = GetMetaItemLayeredResponseSchema.parse(withoutDiagnostics); + expect(parsed._diagnostics).toBeUndefined(); + }); + + it('REQUIRES the four resolved verdicts — this path always sets them', () => { + // Unlike the ordinary read there is one producer path here (the layered + // view skips the cache), so these are guaranteed and declared required. + // A body missing them is not a layered response. + for (const key of ['lock', 'editable', 'deletable', 'resettable'] as const) { + const { [key]: _dropped, ...missing } = LAYERED_BODY; + expect(() => GetMetaItemLayeredResponseSchema.parse(missing)).toThrow(); + } + }); + + it('rejects an overlayScope outside `org` | `env`', () => { + expect(() => + GetMetaItemLayeredResponseSchema.parse({ ...LAYERED_BODY, overlayScope: 'package' }), + ).toThrow(); + }); + + it('is a DIFFERENT shape from the ordinary read — which is why it got its own path', () => { + // The layered body carries no `item`, and the ordinary envelope carries no + // layers. One route answering both is what #5882 was filed about; the + // assertion states that the two really are distinct resources rather than + // one schema that could have absorbed the other. + // Compile-time half: `LayeredCarriesNoItem` / `PlainCarriesNoLayers` at + // module scope. + const layered = GetMetaItemLayeredResponseSchema.parse(LAYERED_BODY); + expect(layered).not.toHaveProperty('item'); + + // And the ordinary schema cannot stand in for it at all: the layered body + // has no `item`, which `GetMetaItemResponseSchema` requires, so it does not + // merely lose the layers on the way through — it fails outright. That is + // the sharpest available statement that these are two resources and not one + // schema stretched over both. + const throughPlain = GetMetaItemResponseSchema.safeParse(LAYERED_BODY); + expect(throughPlain.success).toBe(false); + expect(throughPlain.error?.issues.map((i) => i.path.join('.'))).toContain('item'); + }); +}); diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts index 4648e982f7..17b1166ee3 100644 --- a/packages/spec/src/api/plugin-rest-api.test.ts +++ b/packages/spec/src/api/plugin-rest-api.test.ts @@ -471,7 +471,14 @@ describe('plugin-rest-api.zod', () => { expect(DEFAULT_METADATA_ROUTES.service).toBe('metadata'); expect(DEFAULT_METADATA_ROUTES.category).toBe('metadata'); expect(DEFAULT_METADATA_ROUTES.authRequired).toBe(true); - expect(DEFAULT_METADATA_ROUTES.endpoints).toHaveLength(4); + // 4 -> 5: `GET /:type/:name/layers` (#5882) — the three-layer diagnostic + // 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); + expect(DEFAULT_METADATA_ROUTES.endpoints?.map((e) => `${e.method} ${e.path}`)).toContain( + 'GET /:type/:name/layers', + ); 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 69b7d5afdd..1aef52b556 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -736,7 +736,7 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { prefix: '/api/v1/meta', service: 'metadata', category: 'metadata', - methods: ['getMetaTypes', 'getMetaItems', 'getMetaItem', 'saveMetaItem'], + methods: ['getMetaTypes', 'getMetaItems', 'getMetaItem', 'getMetaItemLayered', 'saveMetaItem'], authRequired: true, endpoints: [ { @@ -783,6 +783,30 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { cacheable: true, cacheTtl: 3600, }, + { + method: 'GET', + path: '/:type/:name/layers', + handler: 'getMetaItemLayered', + category: 'metadata', + public: false, + summary: 'Get a metadata item as its three layers (code / overlay / effective)', + description: + 'Diagnostic projection powering Studio\'s "code default vs override vs effective" ' + + 'comparison: the packaged baseline, the tenant customization row, and the merged ' + + 'result side by side. A DIFFERENT representation from `GET /:type/:name`, which ' + + 'answers only the merged value under `item` — hence its own path and its own ' + + 'response schema (#5882). Reached until now only as `GET /:type/:name?layers=true`, ' + + 'which still works during its deprecation window but is answered with ' + + '`Deprecation` / `Link` headers pointing here.', + tags: ['Metadata'], + // No `requestSchema`, for the same reason as `GET /:type/:name` above + // (#3899): every input is path/query-bound, so no request body exists to + // validate and declaring one would advertise a gate that cannot run. + responseSchema: 'GetMetaItemLayeredResponseSchema', + // Not cacheable: this is a diagnostic read that deliberately bypasses the + // metadata cache so it always reflects the live overlay row. + cacheable: false, + }, { method: 'PUT', path: '/:type/:name', diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 805fbeafc3..c456b5dfac 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -23,6 +23,15 @@ import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod'; import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod'; import { ActionDescriptorSchema } from '../automation/node-executor.zod'; import { TranslationDataSchema } from '../system/translation.zod'; +// #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item +// responses publish. Same three vocabularies the resolver filters against, so a +// value this spec cannot name is a value the resolver would have dropped. +import { + MetadataLockSchema, + MetadataLockSourceSchema, + MetadataProvenanceSchema, +} from '../kernel/metadata-protection.zod'; +import { MetadataValidationResultSchema } from '../kernel/metadata-plugin.zod'; import { ListPackagesRequestSchema, ListPackagesResponseSchema, @@ -228,13 +237,187 @@ export const GetMetaItemRequestSchema = lazySchema(() => z.object({ packageId: z.string().optional().describe('Optional package ID to filter items by'), })); +/** + * ADR-0010 read-side protection envelope — the flags a metadata READ publishes + * alongside the document, all derived from one `resolveLockState()` call. + * + * These are the UN-prefixed, envelope-level counterparts of the `_lock` / + * `_provenance` fields `MetadataProtectionFields` splices into the document + * itself: the document stores `_lock`, and the read RESOLVES it into `lock` + * plus the three `editable` / `deletable` / `resettable` verdicts Studio + * renders affordances from (ADR-0010 §5), so no consumer re-implements the + * lock algebra. + * + * Shared by {@link GetMetaItemResponseSchema} and + * {@link GetMetaItemLayeredResponseSchema} — both are produced by the SAME + * `resolveLockState` call in `metadata-protocol`, so a mixin is what keeps the + * two declarations from drifting apart key by key. Module-local on purpose: it + * is a shape these two responses share, not a new public vocabulary. + * + * Every key is optional HERE and tightened per-response where the producer + * guarantees presence — see each schema's note. Optionality is measured, not + * assumed: the six `lockReason` … `packageVersion` keys are spread only when + * `!== undefined` (they read off `_`-prefixed document fields that are + * themselves optional), so they are conditional on EVERY path. + */ +const MetadataProtectionEnvelopeFields = { + lock: MetadataLockSchema.optional().describe( + 'Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; ' + + '`no-overlay` / `no-delete` / `full` refuse the corresponding write with ' + + '403 `ITEM_LOCKED`. Resolved from the document\'s `_lock`, with the packaged ' + + 'artifact winning over any org overlay.', + ), + lockReason: z.string().optional().describe( + 'Human-readable explanation shown next to a refused write. Present only when ' + + 'the resolved item declares `_lockReason`.', + ), + lockSource: MetadataLockSourceSchema.optional().describe( + 'Which layer asserted the lock. Present only when the resolved item declares ' + + '`_lockSource`.', + ), + lockDocsUrl: z.string().optional().describe( + 'Documentation link surfaced beside `lockReason`. Present only when the ' + + 'resolved item declares `_lockDocsUrl`.', + ), + provenance: MetadataProvenanceSchema.optional().describe( + 'Where the item came from (package | org | env-forced). Present only when the ' + + 'resolved item declares `_provenance`.', + ), + packageId: z.string().optional().describe( + 'Owning package machine id. Present only when the resolved item declares ' + + '`_packageId`.', + ), + packageVersion: z.string().optional().describe( + 'Owning package version. Present only when the resolved item declares ' + + '`_packageVersion`.', + ), + editable: z.boolean().optional().describe( + 'Whether an overlay write is permitted — false iff `lock` is `no-overlay` or ' + + '`full`. A derived verdict: do not recompute it from `lock` client-side.', + ), + deletable: z.boolean().optional().describe( + 'Whether deleting the overlay is permitted — false iff `lock` is `no-delete` ' + + 'or `full`.', + ), + resettable: z.boolean().optional().describe( + '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.', + ), +} as const; + /** * Get Metadata Item Response + * + * Describes the FULL body `GET /api/v1/meta/:type/:name` can return, not the + * three-key subset it used to claim (#5950 — the read-side twin of the write-side + * gap #5745 closed on {@link SaveMetaItemResponseSchema}). + * + * The declaration stopped at `{ type, name, item }` while the uncached branch + * served ten more keys: the ADR-0010 protection envelope, spread onto the wire + * verbatim by the REST layer (`rest-server.ts`'s `translateMetaEnvelope` does + * `{ ...envelope, item }`). `lock` in particular is the read half of the ADR-0008 + * optimistic-concurrency story the write half already declares — so an SDK caller + * typed against this response could not see it, and reading it meant a cast, the + * consumer-side tolerance this repo rejects by Prime Directive #12. + * + * **Why every protection key is optional, measured rather than assumed.** This + * route reaches a body by two branches and they publish different amounts: + * + * - **cached** (`getMetaItemCached`, THE DEFAULT — `enableCache` defaults to + * `true`): the REST layer rebuilds the envelope as `{ type, name, item }` and + * deliberately resolves NO lock — it is the fast published-value path and + * never consults the lock resolver (`rest-server.ts`, the `cachedEnvelope` + * note). All ten keys are ABSENT. + * - **uncached** (`getMetaItem`): `lock`, `editable`, `deletable` and + * `resettable` are always set; the other six appear only when the resolved + * document carries the corresponding `_`-prefixed field. + * + * So `optional` here means "this deployment/branch did not publish it", NEVER + * "unlocked" — a consumer that needs the OCC carriers must read the uncached + * path and must not read absence as `lock: 'none'`. Declaring them required + * would make the default deployment's own response fail its own contract, which + * is the #5563 defect in mirror image. + * + * ⚠️ This is a DECLARATION change only — zero runtime behaviour is altered. That + * lock presence depends on a server-side cache setting is a separate, larger + * question (#5950 says so explicitly) and is deliberately NOT decided here. */ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), item: z.unknown().describe('Metadata item definition'), + ...MetadataProtectionEnvelopeFields, +})); + +/** + * Get Metadata Item — LAYERED Response + * + * The body of `GET /api/v1/meta/:type/:name/layers`: a three-layer diagnostic + * projection that shows the packaged baseline, the tenant's customization row + * and the merged result SIDE BY SIDE, which is what drives Studio's + * "code default vs override vs effective" comparison tabs. + * + * **Why this is a separate schema on a separate path** (#5882, ruled B by the + * maintainer 2026-08-06). This projection used to be reached by putting + * `?layers=true` on the ordinary read, so one route answered two unrelated + * resource representations while `packages/spec` declared only one of them — + * anything generating a client from the route table (SDK annotations, codegen, + * an AI-written integration) produced a parser that was simply wrong for the + * flagged call. Collapsing the three layers into + * {@link GetMetaItemResponseSchema}'s single `item` was never an option: seeing + * the layers apart IS the diagnostic. The rejected alternative was teaching the + * route declaration to express "two shapes, chosen by query flag"; that adds a + * new primitive every future tool must understand, and conditional response + * selection is precisely where codegen and AI clients go wrong. One path, one + * response shape — so the projection got its own path. + * + * The `?layers=` flag still answers this same body during its deprecation + * window, marked with `Deprecation` / `Link` response headers. + * + * **Required vs optional, measured against the producer.** Unlike the ordinary + * read there is exactly ONE producer path here (`getMetaItemLayered`; the + * layered view deliberately skips the cache), so the four resolved verdicts + * `lock` / `editable` / `deletable` / `resettable` are ALWAYS set and are + * required below. The six conditional protection keys stay optional for the + * same reason they are optional on the ordinary read. + */ +export const GetMetaItemLayeredResponseSchema = lazySchema(() => z.object({ + type: z.string().describe('Metadata type name (canonical singular)'), + name: z.string().describe('Item name'), + code: z.unknown().describe( + 'LAYER 1 — the packaged artifact baseline exactly as shipped, before any ' + + 'tenant customization. `null` when no artifact ships this item (it exists ' + + 'only as an overlay).', + ), + overlay: z.unknown().describe( + 'LAYER 2 — the stored customization row ALONE, not merged with `code`. ' + + '`null` when this tenant has not customized the item.', + ), + overlayScope: z.enum(['org', 'env']).nullable().describe( + 'Which scope the `overlay` row was read from — `org` for a tenant overlay, ' + + '`env` for an environment-level one. `null` exactly when `overlay` is null.', + ), + effective: z.unknown().describe( + 'LAYER 3 — the merged result, i.e. the value an ordinary ' + + '`GET /meta/:type/:name` would return under `item`. `null` when the item ' + + 'resolves to nothing at all.', + ), + _diagnostics: MetadataValidationResultSchema.optional().describe( + 'Load-time spec-validation verdict for `effective`, so the Studio edit page ' + + 'can raise invalid-metadata banners and inline field errors without a ' + + 'second round trip. ABSENT for metadata types that register no Zod schema ' + + '(function / service / router) — absence means "no opinion", never "valid".', + ), + ...MetadataProtectionEnvelopeFields, + // The four resolved verdicts are unconditional on this single-producer path — + // tightened from the mixin's optional baseline. See the note above. + lock: MetadataLockSchema.describe( + 'Resolved lock verdict (ADR-0010 §3.3), artifact winning over overlay. Always ' + + 'present on this path.', + ), + editable: z.boolean().describe('Whether an overlay write is permitted. Always present on this path.'), + deletable: z.boolean().describe('Whether deleting the overlay is permitted. Always present on this path.'), + resettable: z.boolean().describe('Whether the item can be reset to its packaged default. Always present on this path.'), })); /** @@ -1353,6 +1536,7 @@ export type GetMetaItemsRequest = z.input; export type GetMetaItemsResponse = z.input; export type GetMetaItemRequest = z.input; export type GetMetaItemResponse = z.input; +export type GetMetaItemLayeredResponse = z.input; export type SaveMetaItemRequest = z.input; export type SaveMetaItemResponse = z.input; export type DeleteMetaItemRequest = 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 1e1dbf05d7..8dfc88732c 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -459,6 +459,7 @@ export type Iso131 = Assert, export type Iso132 = Assert, z.infer< typeof M28.GetMetaItemsResponseSchema > >>; export type Iso133 = Assert, z.infer< typeof M28.GetMetaItemRequestSchema > >>; export type Iso134 = Assert, z.infer< typeof M28.GetMetaItemResponseSchema > >>; +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 Iso137 = Assert, z.infer< typeof M28.DeleteMetaItemRequestSchema > >>; @@ -1730,9 +1731,21 @@ describe('ADR-0122 type-alias convention', () => { // re-added to 749 rather than to the 748 it was computed against — the same // rebase discipline the -7 entry above records, applied in the other // direction. + // + // 822 -> 823 is #6487's `GetMetaItemLayeredResponseSchema` (#5882): the + // three-layer projection `GET /meta/:type/:name/layers` now declares. Its + // tree is enums, booleans, `z.unknown()` and + // `MetadataValidationResultSchema` — no `.default()` anywhere — so its two + // shapes coincide and ADR-0122 gives it a pin. Sweep siblings #5950 and + // #6442 contribute 0 (already pinned / carries a Parsed alias). This + // receipt was written twice before landing — as 749 -> 750 against the + // pre-backfill count — and recomputed here after #4593's +73 merged in; + // the pin also took its THIRD number (759 -> 760 -> 833), each time + // because the next-free id had been claimed by a branch that merged + // first. The file, not the history, is the operand. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert {