From b0eb4a45797dff0d5de7af77582c72768c84f98b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 04:40:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec):=20declare=20the=20batch=20publish?= =?UTF-8?q?=20response=20schema=20=E2=80=94=20probes=20staged=20opaque=20(?= =?UTF-8?q?#9406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublishPackageDraftsResponseSchema declares the full wire payload of POST /packages/:id/publish-drafts (the #5745/#7294 declared-=-returned discipline on the batch door), including the REST door's response mutations (seedApplied back-fill, ADR-0045 unhiddenApps/unhideError, rebindError). probes is deliberately opaque per the #9406 ruling. Pin suites: spec declaration pins, objectql producer conformance, runtime route conformance; route ledger names the schema; client packages.publishDrafts resolves the declared type instead of any. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- .changeset/batch-publish-response-declared.md | 7 + content/docs/references/api/protocol.mdx | 26 +- content/docs/references/index.mdx | 10 +- ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/client/src/index.ts | 17 +- packages/metadata-protocol/src/protocol.ts | 12 + ...ackage-drafts-response-conformance.test.ts | 377 ++++++++++++++++++ ...ublish-drafts-response-conformance.test.ts | 207 ++++++++++ packages/runtime/src/route-ledger.ts | 4 +- packages/spec/api-surface/api.json | 2 + packages/spec/authorable-surface/api.json | 12 + packages/spec/export-origins/api.json | 2 + packages/spec/json-schema.manifest/api.json | 1 + packages/spec/src/api/protocol.test.ts | 261 ++++++++++++ packages/spec/src/api/protocol.zod.ts | 206 ++++++++++ .../src/type-alias-convention.pin.test.ts | 19 +- 16 files changed, 1151 insertions(+), 14 deletions(-) create mode 100644 .changeset/batch-publish-response-declared.md create mode 100644 packages/objectql/src/publish-package-drafts-response-conformance.test.ts create mode 100644 packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts diff --git a/.changeset/batch-publish-response-declared.md b/.changeset/batch-publish-response-declared.md new file mode 100644 index 0000000000..e8c1a8eef6 --- /dev/null +++ b/.changeset/batch-publish-response-declared.md @@ -0,0 +1,7 @@ +--- +"@objectstack/spec": minor +"@objectstack/client": minor +"@objectstack/runtime": patch +--- + +The batch publish response is declared (#9406). `POST /packages/:id/publish-drafts` (Studio's "publish whole app") now has a spec contract behind it: `PublishPackageDraftsResponseSchema` in `@objectstack/spec/api` declares the full wire payload — `success` / `publishedCount` / `failedCount` / `published[]` (each element with its ADR-0008 `version` OCC token and the optional omitted-when-empty `advisories` from #9343) / `failed[]` / `seedApplied` / `materializeApplied` / `commitId`, plus the REST door's own receipts (`unhiddenApps` / `unhideError` / `rebindError`) — the #5745/#7294 "declared = returned" discipline carried to the batch door, with the two pin suites mirroring the single door's pair (spec-side declaration pins plus producer- and route-side conformance gates). `probes` is deliberately opaque in the declaration per the #9406 ruling: the key is declared and carried through verbatim, but its inner `BuildProbeReport` shape is staged until a consumer needs a field of it. `@objectstack/client`'s `packages.publishDrafts` now resolves `PublishPackageDraftsResponse` instead of `any`, and the runtime route ledger names the schema. Additive declaration of an existing wire face — no response bytes change. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index a29481d641..22860de089 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -12,8 +12,8 @@ description: Protocol protocol schemas ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, PublishMetaItemResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RuntimeAuthoringIssue, 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); @@ -1215,6 +1215,28 @@ List packages response | **message** | `string` | optional | Human-readable receipt, e.g. `Published draft — type=view, name=cases [seq=3]`. The producer sets it on every publish today; it stays optional to match the producer's own signature and its `SaveMetaItemResponse` twin, and because an absent human-readable string strips no data — the failure mode #5745 exists to prevent. | +--- + +## PublishPackageDraftsResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **success** | `boolean` | ✅ | True only when every pending draft promoted (`failed` empty) AND at least one item published. A pre-flight refusal or an ADR-0067 D2 rollback answers false on a 200 — read `failed[]`, not the HTTP status. It does NOT cover the best-effort receipts below, each of which reports its own `success`. | +| **publishedCount** | `integer` | ✅ | Number of drafts promoted to active — `published.length`. 0 on every refusal path (the batch is all-or-nothing, ADR-0067 D2). | +| **failedCount** | `integer` | ✅ | Number of items that did not publish — `failed.length`. On a rollback this counts the WHOLE batch: the causal item plus every sibling marked BATCH_ABORTED. | +| **published** | `{ type: string; name: string; version: string; advisories?: object[] }[]` | ✅ | Every draft promoted to active, in publish order. Empty on every refusal path. | +| **failed** | `{ type: string; name: string; error: string; code?: string }[]` | ✅ | Items that did not publish. Because the batch is all-or-nothing (ADR-0067 D2), a non-empty list means NOTHING landed: `published: []`, `publishedCount: 0`. | +| **seedApplied** | `{ success: boolean; inserted?: integer; updated?: integer; error?: string; … }` | optional | Aggregate outcome of materializing EVERY published `seed` body in one multi-pass loader run (cross-seed references need the whole set). Present ONLY when the batch published at least one seed. Two producers, one key: the batch itself self-applies (`applySeedBodies`), and the REST door back-fills the same key for custom protocols that do not — never both (an externalId-less seed would double-insert). Best-effort: a seed problem is surfaced here, never thrown. | +| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; failures: object[] }` | optional | ADR-0086 P2 — aggregate result of publish-time materializers across the batch (e.g. `permission` → `sys_permission_set`), including side-effect failures surfaced by the per-item effects loop. Present ONLY when at least one published item had a registered materializer or a side-effect failure. Best-effort, same contract as `seedApplied`. | +| **probes** | `any` | optional | ADR-0038 L3 post-publish runtime probe report — one real read per published artifact (seeded objects have rows, views are readable, widget dataset selections execute). DELIBERATELY OPAQUE in this contract (#9406): the key is declared and carried through verbatim, but its inner shape is intentionally not modeled until a consumer needs a field of it. Present only when something was publishable; probes never fail the publish. | +| **commitId** | `string` | optional | ADR-0067 — id of the commit this publish recorded. Absent when nothing published. | +| **unhiddenApps** | `string[]` | optional | ADR-0045 §3 — names of the apps whose `_unpublished` gate this publish cleared (publish = live AND visible; a materialized additive build has no drafts, only this flip). Attached by the REST door, not the protocol helper. Present ONLY when at least one app flipped — on a mid-loop failure it names the apps that DID persist, beside `unhideError` (#5242's split report). Spelling is a permanent wire contract (see the door's #6955 note). | +| **unhideError** | `string` | optional | Present when the ADR-0045 visibility flip failed (wholly or partway): the drafts ARE published, but apps still stored `_unpublished: true` stay externally unobservable. Client-facing text only — undeclared driver text is withheld per ADR-0112 (#8516); the full cause is in the server log. The route is idempotent: re-run it once the cause is resolved. | +| **rebindError** | `string` | optional | Present when the post-publish `metadata:reloaded` announce failed: everything is published and stored, but boot-cached consumers keep the pre-publish view until re-run or restart (a newly published record-triggered flow does not bind its trigger). Client-facing text only, same ADR-0112 withhold as `unhideError` (#8516). | + + --- ## RealtimeConnectRequest diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index b586a40cdc..e357f7fabb 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 — 1586 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1587 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 | 413 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 28 | 414 | 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 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 161 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1586** | 14 protocol modules | +| **Total** | **198** | **1587** | 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, 413 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **28 pages, 414 schemas** REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. @@ -86,7 +86,7 @@ REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | [`odata.zod.ts`](/docs/references/api/odata) | `ODataConfig`, `ODataError`, `ODataFilterFunction`, `ODataMetadata`, `ODataQuery`, `ODataResponse` | | [`package-api.zod.ts`](/docs/references/api/package-api) | `GetInstalledPackageRequest`, `GetInstalledPackageResponse`, `ListInstalledPackagesRequest`, `ListInstalledPackagesResponse`, `PackageApiErrorCode`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageRollbackResponse`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `HandlerStatus`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `RouteCoverageEntry`, `RouteCoverageReport`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RuntimeAuthoringIssue`, `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 ea3246ce13..f10002b97a 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -258,7 +258,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 399 | +| `api/` | 405 | | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 10 | diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index bb1bd9b20c..b101435868 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,6 +17,7 @@ import { GetMetaItemResponse, SaveMetaItemResponse, PublishMetaItemResponse, + PublishPackageDraftsResponse, LoginRequest, SessionResponse, GetPresignedUrlRequest, @@ -1285,14 +1286,25 @@ export class ObjectStackClient { * ADR-0033 "publish whole app": promote every pending draft bound to the * package to active in one shot. Published `seed` drafts also materialize * their rows (reported under `seedApplied`). + * + * Nameable at the type level only since #9406, which declared + * `PublishPackageDraftsResponseSchema` — the batch sibling of #7294's + * single-item declaration; this method resolved to `any` before that. + * The batch is all-or-nothing (ADR-0067 D2): `success: false` on a 200 + * means NOTHING landed — read `failed[]`. Each `published[]` element's + * `version` is the ADR-0008 OCC token (echo as `If-Match`), and the + * conditional receipts (`seedApplied` / `materializeApplied` / + * `unhiddenApps` / `unhideError` / `rebindError`) each report their own + * outcome: a 200 does not mean the data plane or the visibility flip + * caught up. `probes` is deliberately opaque in the contract (#9406). */ - publishDrafts: async (id: string, opts?: { actor?: string }) => { + publishDrafts: async (id: string, opts?: { actor?: string }): Promise => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/publish-drafts`, { method: 'POST', body: JSON.stringify(opts ?? {}), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** ADR-0033: drop every pending draft bound to the package. */ @@ -5485,6 +5497,7 @@ export type { GetMetaItemResponse, SaveMetaItemResponse, PublishMetaItemResponse, + PublishPackageDraftsResponse, CheckPermissionRequest, CheckPermissionResponse, GetObjectPermissionsResponse, diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index aedc09e1fd..f2a5c9f5fb 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -14269,6 +14269,18 @@ export class ObjectStackProtocolImplementation implements * `metadata` service (unlike `MetadataService.publishPackage`, which reads * the in-memory registry and 503s when that service is absent). Per-item * failures are collected and do NOT abort the rest. + * + * [#9406] The wire face of this return is DECLARED: + * `PublishPackageDraftsResponseSchema` in `@objectstack/spec/api` (the + * #5745/#7294 "declared = returned" pair, batch door). The declaration + * covers this object PLUS the REST door's mutations (`seedApplied` + * back-fill and the ADR-0045 `unhiddenApps`/`unhideError`/`rebindError` + * receipts in `packages/runtime/src/domains/packages.ts`). Changing this + * return type means updating the schema and its two conformance suites + * (`packages/objectql/src/publish-package-drafts-response-conformance.test.ts`, + * `packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts`) + * in the same PR. `probes` is deliberately opaque in the declaration — + * upgrade it only on a consumer-driven card (#9406 ruling). */ async publishPackageDrafts(request: { packageId: string; diff --git a/packages/objectql/src/publish-package-drafts-response-conformance.test.ts b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts new file mode 100644 index 0000000000..4dbd739aa4 --- /dev/null +++ b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts @@ -0,0 +1,377 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9406 — conformance gate: the body `publishPackageDrafts` really returns + * must parse through `PublishPackageDraftsResponseSchema` with NOTHING + * stripped. + * + * This is the producer side of the batch declaration — the batch sibling of + * `publish-meta-response-conformance.test.ts` (#7294), which is the stated + * precedent one door over. The spec-side suite + * (`packages/spec/src/api/protocol.test.ts`) pins what the schema says; this + * one pins that the schema still matches what the code emits, driving the REAL + * protocol against a REAL ObjectQL engine. The two together are what makes + * "declared = returned" checkable — a future field added to the response, or + * an existing one dropped, turns this red instead of silently vanishing at + * parse. + * + * Why the REST layer needs its OWN case here, unlike the single door: the + * dispatcher route (`packages/runtime/src/domains/packages.ts`) does NOT hand + * this object to the wire verbatim — it back-fills `seedApplied` for custom + * protocols and attaches the ADR-0045 receipts (`unhiddenApps` / + * `unhideError` / `rebindError`) before responding. Those mutations are pinned + * route-side in + * `packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts`; + * THIS file owns the helper's half of the wire face. + * + * `probes` is deliberately opaque in the declaration (#9406 ruling: staged, + * upgrade only when a consumer needs a field) — so the cases here assert it is + * carried through unstripped, never its inner shape. + */ +import { describe, it, expect } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { PublishPackageDraftsResponseSchema } from '@objectstack/spec/api'; +import { ObjectQL } from './engine.js'; + +const sysMetadataObject: ServiceObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +/** + * The same in-memory driver the two single-door gates use. Kept local rather + * than shared, for the reason `publish-meta-response-conformance.test.ts` + * records: each gate is a tripwire that must be able to fail independently. + */ +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +async function makeProtocol() { + const engine = new ObjectQL(); + const { driver } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject, 'test-package'); + return new ObjectStackProtocolImplementation(engine); +} + +const PKG = 'app.edu'; + +const viewBody = (name: string, label: string) => ({ + name, type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list', +}); + +/** + * The advisory-bearing flow, verbatim from the single publish door's #9176 + * measurement: its ONLY defect is a `delete_record` node declaring + * `multi: true` with no `filter`, so `lintFlowPatterns` raises + * `flow-multi-write-unfiltered` at `severity: 'warning'` — the promotion is + * NOT refused and the finding rides the element. + */ +const advisoryFlow = () => ({ + name: 'nightly_purge', + label: 'Nightly Purge', + type: 'autolaunched', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'purge', + type: 'delete_record', + label: 'Purge', + config: { objectName: 'audit_logs', multi: true }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'purge' }], +}); + +/** A `seed` body — what makes the batch's aggregate `seedApplied` appear. */ +const seedBody = { object: 'sys_metadata', records: [{ name: 'row_a', type: 'view' }] }; + +/** + * Stage drafts bound to PKG and publish the whole package — the REST pair + * `PUT /meta/:type/:name?mode=draft` (with `packageId`) + + * `POST /packages/:id/publish-drafts`. Env-wide (`organizationId` absent) on + * purpose: `flow` and `seed` declare `allowOrgOverride: false`, so an + * org-scoped draft would be refused before the door under test runs. + */ +async function stageDrafts( + p: ObjectStackProtocolImplementation, + drafts: Array<{ type: string; name: string; item: unknown }>, +): Promise { + for (const d of drafts) { + await (p as any).saveMetaItem({ + type: d.type, name: d.name, item: d.item, mode: 'draft', packageId: PKG, + }); + } +} + +/** Keys the producer emitted that the schema refused to carry through. */ +function strippedKeys(raw: Record): string[] { + const parsed = PublishPackageDraftsResponseSchema.parse(raw) as Record; + return Object.keys(raw).filter((k) => !(k in parsed)); +} + +describe('publishPackageDrafts response conforms to PublishPackageDraftsResponseSchema (#9406)', () => { + it('plain batch publish: parses green and strips nothing', async () => { + const p = await makeProtocol(); + await stageDrafts(p, [ + { type: 'view', name: 'cases', item: viewBody('cases', 'Cases') }, + { type: 'view', name: 'leads', item: viewBody('leads', 'Leads') }, + ]); + + const raw: any = await p.publishPackageDrafts({ packageId: PKG }); + + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(raw); + expect(parsed.success).toBe(true); + expect(parsed.publishedCount).toBe(2); + expect(parsed.failedCount).toBe(0); + expect(parsed.failed).toEqual([]); + // Every element carries the ADR-0008 OCC token, unstripped. + for (const el of parsed.published) { + expect(typeof el.version).toBe('string'); + expect(el.version.length).toBeGreaterThan(0); + } + expect(parsed.published.map((e) => e.name).sort()).toEqual(['cases', 'leads']); + }); + + it('the five always-emitted keys are required — the producer sets them on every return site', async () => { + const p = await makeProtocol(); + await stageDrafts(p, [{ type: 'view', name: 'cases', item: viewBody('cases', 'Cases') }]); + const raw: any = await p.publishPackageDrafts({ packageId: PKG }); + + for (const key of ['success', 'publishedCount', 'failedCount', 'published', 'failed'] as const) { + expect(raw[key], `producer must emit '${key}'`).toBeDefined(); + const body: Record = { ...raw }; + delete body[key]; + expect( + PublishPackageDraftsResponseSchema.safeParse(body).success, + `omitting '${key}' must fail parse`, + ).toBe(false); + } + }); + + it('probes rides the response opaque: carried through parse unstripped, shape unconstrained (#9406 staging)', async () => { + const p = await makeProtocol(); + await stageDrafts(p, [{ type: 'view', name: 'cases', item: viewBody('cases', 'Cases') }]); + const raw: any = await p.publishPackageDrafts({ packageId: PKG }); + + // ADR-0038 L3: something was publishable, so the probe pass really ran. + // This is the assertion that keeps "opaque" from decaying into + // "absent": the KEY is part of the declared face even though its inner + // shape deliberately is not. + expect(Object.keys(raw)).toContain('probes'); + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(raw); + // Reference equality through parse — z.unknown() passes the value + // through; a modeled schema that strips would break this first. + expect(parsed.probes).toBe(raw.probes); + }); + + it('publishing a seed: the aggregate seedApplied is carried through with its counters', async () => { + const p = await makeProtocol(); + await stageDrafts(p, [{ type: 'seed', name: 'demo_rows', item: seedBody }]); + const raw: any = await p.publishPackageDrafts({ packageId: PKG }); + + expect(Object.keys(raw)).toContain('seedApplied'); + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(raw); + expect(parsed.success).toBe(true); + // The in-batch producer (`applySeedBodies`) always emits both + // counters — the optionality the schema grants them exists for the + // ROUTE-level fallback producer only (pinned in the runtime suite). + expect(parsed.seedApplied?.success).toBe(true); + expect(parsed.seedApplied?.inserted).toBe(1); + expect(parsed.seedApplied?.updated).toBe(0); + }); + + it('a materializer across the batch: materializeApplied aggregates with per-item failures[]', async () => { + const p = await makeProtocol(); + p.registerPublishMaterializer('view', async () => { throw new Error('boom-from-materializer'); }); + await stageDrafts(p, [{ type: 'view', name: 'cases', item: viewBody('cases', 'Cases') }]); + + const raw: any = await p.publishPackageDrafts({ packageId: PKG }); + + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(raw); + // Best-effort by contract: the materializer failed, the publish + // succeeded, and the failure is NAMED per item — the batch shape's + // deliberate difference from the single door's scalar `error`. + expect(parsed.success).toBe(true); + expect(parsed.materializeApplied?.success).toBe(false); + expect(parsed.materializeApplied?.failures).toHaveLength(1); + expect(parsed.materializeApplied?.failures[0]).toMatchObject({ type: 'view', name: 'cases' }); + }); + + it('advisories ride their published[] element and survive the declared parse (#9343)', async () => { + const p = await makeProtocol(); + await stageDrafts(p, [ + { type: 'flow', name: 'nightly_purge', item: advisoryFlow() }, + { type: 'view', name: 'cases', item: viewBody('cases', 'Cases') }, + ]); + + const raw: any = await p.publishPackageDrafts({ packageId: PKG }); + + expect(raw.success).toBe(true); + const flowEl = raw.published.find((e: any) => e.name === 'nightly_purge'); + const viewEl = raw.published.find((e: any) => e.name === 'cases'); + // The finding reached its OWN element — and only its own. + expect(flowEl.advisories).toHaveLength(1); + expect(flowEl.advisories[0].rule).toBe('flow-multi-write-unfiltered'); + expect(flowEl.advisories[0].severity).toBe('warning'); + expect('advisories' in viewEl).toBe(false); + + // …and the parse strips none of it. This is the assertion that goes + // red if the element declaration loses `advisories` or either side + // renames the key: a plain z.object STRIPS what it does not declare. + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(raw); + const parsedFlowEl = parsed.published.find((e) => e.name === 'nightly_purge'); + expect(parsedFlowEl?.advisories).toEqual(flowEl.advisories); + expect(Object.keys(parsedFlowEl!.advisories![0]!).sort()) + .toEqual(['hint', 'message', 'path', 'rule', 'severity', 'where']); + }); + + /** + * Byte-stability, the producer half (#9406 dispatch condition): an + * advisory-free batch publish carries no trace of `advisories` on the + * wire, and the DECLARED parse fabricates nothing — the declaration must + * not change wire bytes, and a `.default()` anywhere in the schema would + * change what consumers observe relative to those bytes. Pinned on the + * raw KEY SETS and the serialized wire, not through the parse alone, + * because the spec declares the key and so `strippedKeys` stays `[]` + * whether or not the producer went quiet. + */ + it('byte-stability: an advisory-free publish carries no advisories key, and parse fabricates nothing', async () => { + const p = await makeProtocol(); + await stageDrafts(p, [{ type: 'view', name: 'cases', item: viewBody('cases', 'Cases') }]); + const raw: any = await p.publishPackageDrafts({ packageId: PKG }); + + // JSON.stringify is the wire, and the wire is the promise. + expect(JSON.stringify(raw)).not.toContain('advisories'); + for (const el of raw.published) expect('advisories' in el).toBe(false); + + const parsed = PublishPackageDraftsResponseSchema.parse(raw) as Record; + expect(Object.keys(parsed).sort()).toEqual(Object.keys(raw).sort()); + for (let i = 0; i < raw.published.length; i += 1) { + expect(Object.keys((parsed.published as any[])[i]).sort()) + .toEqual(Object.keys(raw.published[i]).sort()); + } + }); + + /** + * The ADR-0067 D2 refusal face — `success: false` on a fully-shaped body, + * never a throw: a pre-flight violation fails the WHOLE batch with + * `publishedCount: 0`, `published: []` and every item accounted for in + * `failed[]`. Refusal-shape drift is exactly as silent as success-shape + * drift, so the gate covers both. + */ + it('a refused batch still conforms: the all-or-nothing failed[] face parses unstripped', async () => { + const p = await makeProtocol(); + // A package whose registry entry declares a namespace, and an object + // draft that violates the ADR-0028 prefix rule — the measured + // pre-flight refusal class. + (p as any).engine.registry.registerPackage?.({ + id: PKG, manifest: { namespace: 'edu' }, + }); + await stageDrafts(p, [ + { type: 'object', name: 'ticket', item: { name: 'ticket', label: 'Ticket', fields: {} } }, + ]); + + const raw: any = await p.publishPackageDrafts({ packageId: PKG }); + + expect(raw.success).toBe(false); + expect(raw.publishedCount).toBe(0); + expect(raw.published).toEqual([]); + expect(raw.failedCount).toBeGreaterThan(0); + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(raw); + expect(parsed.failed[0]).toMatchObject({ type: 'object', name: 'ticket' }); + expect(typeof parsed.failed[0]!.error).toBe('string'); + expect(typeof parsed.failed[0]!.code).toBe('string'); + }); +}); diff --git a/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts b/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts new file mode 100644 index 0000000000..c018ce7143 --- /dev/null +++ b/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9406 — route-side conformance: the payload `POST /packages/:id/publish-drafts` + * actually serves must parse through `PublishPackageDraftsResponseSchema` with + * NOTHING stripped. + * + * The batch door differs from the single-item publish door (#7294) in exactly + * one structural way: the REST layer does NOT hand the protocol's return to + * the wire verbatim. `handlePackages` mutates the object before responding — + * it back-fills `seedApplied` for custom protocols that do not self-apply + * (#8443) and attaches the ADR-0045 receipts `unhiddenApps` / `unhideError` + * (#5242/#8516) and the announce receipt `rebindError` (#8516) — and then + * wraps it in the dispatcher's `{ success, data }` envelope. So the declared + * schema names the **`data` payload** (the same payload-level convention the + * ledger records for `DiscoverySchema`), and only a gate that drives the REAL + * route can certify the route-attached keys. This file is that gate — it is + * the coverage the ledger's `responseSchema` field is forbidden to be written + * without (`route-ledger.ts` header), and the reason the + * `POST /packages/:id/publish-drafts` row may carry + * `responseSchema: 'PublishPackageDraftsResponseSchema'`. + * + * The protocol producer's own half of the face (counts, elements, advisories, + * in-batch seed/materialize aggregates, opaque `probes`) is pinned against the + * real `ObjectStackProtocolImplementation` in + * `packages/objectql/src/publish-package-drafts-response-conformance.test.ts`; + * here the protocol is a double and the flip loop, the announce, the seed + * back-fill and the response assembly under test are shipping code — the same + * split `packages-flip-announce-disclosure.test.ts` (#8516) uses, whose + * harness this file borrows. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PublishPackageDraftsResponseSchema } from '@objectstack/spec/api'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +/** + * [#7033 / #7023] `/packages` demands `manage_metadata` on every + * state-changing route — without a caller these cases would stop at the 401. + */ +const PKG_ADMIN = () => ({ + request: {}, + executionContext: { + userId: 'u_pkg_admin', + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, +}) as any; + +/** Keys the wire payload carried that the schema refused to carry through. */ +function strippedKeys(raw: Record): string[] { + const parsed = PublishPackageDraftsResponseSchema.parse(raw) as Record; + return Object.keys(raw).filter((k) => !(k in parsed)); +} + +/** + * A protocol double whose `publishPackageDrafts` answers the real producer's + * happy shape (measured in the objectql conformance suite), plus the app reads + * and writes the ADR-0045 flip loop makes. Opt-in failure injections reach the + * three route-attached keys. + */ +function makeDoor(opts: { + /** The batch result the protocol double answers. */ + result?: Record; + /** Omit `getMetaItem` so the route-level seed back-fill reports its no-counter failure shape. */ + failSaveMetaItem?: boolean; + failTrigger?: boolean; + apps?: Array>; +} = {}) { + const result = opts.result ?? { + success: true, publishedCount: 1, failedCount: 0, + published: [{ type: 'flow', name: 'nightly_rollup', version: 'sha256:aa11' }], failed: [], + probes: { issues: [], checked: { seeds: 0, views: 0, widgets: 0 } }, + commitId: 'cmt_01', + }; + const publishPackageDrafts = vi.fn().mockImplementation(async () => + JSON.parse(JSON.stringify(result))); + const apps = opts.apps ?? [ + { name: 'crm', label: 'CRM', _unpublished: true }, + { name: 'ops', label: 'Ops', _unpublished: true }, + ]; + const getMetaItems = vi.fn().mockImplementation(async () => ({ items: apps.map((a) => ({ ...a })) })); + const saveMetaItem = vi.fn().mockImplementation(async () => { + if (opts.failSaveMetaItem) throw new Error('SQLITE_ERROR: no such table: sys_metadata'); + return { ok: true }; + }); + const trigger = vi.fn().mockImplementation(async () => { + if (opts.failTrigger) throw new Error('TypeError: internal subscriber crash'); + }); + const kernel: any = { + getService: (name: string) => { + if (name === 'protocol') { + // Deliberately NO `getMetaItem`: the route-level seed + // back-fill demands it and reports "required services + // unavailable" without counters — the measured union arm that + // makes the schema's counters optional. + return Promise.resolve({ publishPackageDrafts, getMetaItems, saveMetaItem }); + } + if (name === 'objectql') { + return Promise.resolve({ + insert: vi.fn(), find: vi.fn(), update: vi.fn(), + registry: { getAllPackages: vi.fn().mockReturnValue([]) }, + }); + } + if (name === 'metadata') return Promise.resolve({ getObject: vi.fn() }); + return null; + }, + context: { getService: () => null, trigger }, + }; + return { dispatcher: new HttpDispatcher(kernel), publishPackageDrafts, saveMetaItem, trigger }; +} + +async function publishDrafts(opts: Parameters[0] = {}) { + const door = makeDoor(opts); + const result = await door.dispatcher.handlePackages( + '/com.workspace/publish-drafts', 'POST', {}, {}, PKG_ADMIN(), + ); + expect(result.response?.status).toBe(200); + const body: any = (result.response as any)?.body; + return { ...door, body, data: body?.data }; +} + +const spyLogs = () => ({ + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), +}); +afterEach(() => vi.restoreAllMocks()); + +describe('publish-drafts wire payload conforms to PublishPackageDraftsResponseSchema (#9406)', () => { + it('the schema names the data payload of the { success, data } envelope, and strips nothing', async () => { + const { body, data } = await publishDrafts(); + + // The envelope is the dispatcher's, not this contract's — the same + // payload-level convention the ledger records for DiscoverySchema. + expect(body.success).toBe(true); + expect(data).toBeDefined(); + + expect(strippedKeys(data)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(data); + expect(parsed.success).toBe(true); + expect(parsed.published[0]!.version).toBe('sha256:aa11'); + // The route-attached ADR-0045 receipt is ON the wire and declared. + expect(parsed.unhiddenApps).toEqual(['crm', 'ops']); + // Opaque probes crossed the route untouched. + expect(parsed.probes).toEqual({ issues: [], checked: { seeds: 0, views: 0, widgets: 0 } }); + expect(parsed.commitId).toBe('cmt_01'); + }); + + it('a mid-flip failure serves the #5242 split report, and it conforms', async () => { + spyLogs(); + const { data } = await publishDrafts({ failSaveMetaItem: true }); + + expect(strippedKeys(data)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(data); + expect(parsed.unhideError).toBe('visibility flip failed'); + expect(parsed.unhiddenApps).toBeUndefined(); + }); + + it('an announce failure serves rebindError, and it conforms', async () => { + spyLogs(); + const { data } = await publishDrafts({ failTrigger: true }); + + expect(strippedKeys(data)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(data); + expect(parsed.rebindError).toBe('metadata:reloaded announce failed'); + expect(parsed.unhiddenApps).toEqual(['crm', 'ops']); + }); + + it('the route-level seed back-fill\'s no-counter failure shape conforms — the measured union arm', async () => { + spyLogs(); + const { data } = await publishDrafts({ + result: { + success: true, publishedCount: 1, failedCount: 0, + published: [{ type: 'seed', name: 'demo_rows', version: 'sha256:bb22' }], failed: [], + // NO seedApplied: a custom protocol that does not self-apply. + // The door back-fills it via `applyPublishedSeeds`, which — the + // double having no `getMetaItem` — answers its early-failure + // shape without counters. This is the producer arm that makes + // `seedApplied.inserted`/`updated` optional in the schema. + }, + apps: [], + }); + + expect(strippedKeys(data)).toEqual([]); + const parsed = PublishPackageDraftsResponseSchema.parse(data); + expect(parsed.seedApplied?.success).toBe(false); + expect(typeof parsed.seedApplied?.error).toBe('string'); + expect(parsed.seedApplied?.inserted).toBeUndefined(); + expect(parsed.seedApplied?.updated).toBeUndefined(); + }); + + it('byte-stability across the route: nothing on the serving path parses this schema, so declaring it moved no bytes', async () => { + const { data } = await publishDrafts({ apps: [] }); + + // An advisory-free, flip-free publish: the wire carries none of the + // conditional keys, and the declared parse fabricates none of them — + // the schema has no `.default()`, which is what "the declaration must + // not change wire bytes" cashes out to on the consumer side. + const wire = JSON.stringify(data); + for (const key of ['advisories', 'unhiddenApps', 'unhideError', 'rebindError', 'seedApplied']) { + expect(wire).not.toContain(key); + } + const parsed = PublishPackageDraftsResponseSchema.parse(data) as Record; + // Key SETS, not serialized equality: zod rebuilds objects in shape + // order, so key order is not part of the claim — presence is. + expect(Object.keys(parsed).sort()).toEqual(Object.keys(data).sort()); + expect(parsed).toEqual(data); + }); +}); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 742b019f19..4426547d6f 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -300,7 +300,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'PATCH /packages/:id/disable', domain: '/packages', disposition: 'sdk', client: 'packages.disable' }, { route: 'PATCH /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.update' }, { route: 'POST /packages/:id/publish', domain: '/packages', disposition: 'sdk', client: 'packages.publish' }, - { route: 'POST /packages/:id/publish-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.publishDrafts' }, + { route: 'POST /packages/:id/publish-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.publishDrafts', + responseSchema: 'PublishPackageDraftsResponseSchema', + note: '[#9406] dispatch() answers through the { success, data } envelope, so the named schema is the `data` — the protocol result AFTER the door\'s own mutations (seedApplied back-fill, ADR-0045 unhiddenApps/unhideError, rebindError). Fillable because packages-publish-drafts-response-conformance.test.ts drives THIS handler and parses the payload it answers; the producer half is pinned in objectql\'s publish-package-drafts-response-conformance.test.ts. `probes` is deliberately opaque in the declaration (#9406 ruling)' }, { route: 'POST /packages/:id/discard-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.discardDrafts' }, { route: 'GET /packages/:id/commits', domain: '/packages', disposition: 'sdk', client: 'packages.listCommits' }, { route: 'POST /packages/:id/commits/:commitId/revert', domain: '/packages', disposition: 'sdk', client: 'packages.revertCommit' }, diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 1039b291f8..5f7c960288 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -730,6 +730,8 @@ "PresignedUrlResponseSchema (const)", "PublishMetaItemResponse (type)", "PublishMetaItemResponseSchema (const)", + "PublishPackageDraftsResponse (type)", + "PublishPackageDraftsResponseSchema (const)", "QueryAdapterConfig (type)", "QueryAdapterConfigParsed (type)", "QueryAdapterConfigSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index ef6345722f..27908ccbbb 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1267,6 +1267,18 @@ "api/PublishMetaItemResponse:seq", "api/PublishMetaItemResponse:success", "api/PublishMetaItemResponse:version", + "api/PublishPackageDraftsResponse:commitId", + "api/PublishPackageDraftsResponse:failed", + "api/PublishPackageDraftsResponse:failedCount", + "api/PublishPackageDraftsResponse:materializeApplied", + "api/PublishPackageDraftsResponse:probes", + "api/PublishPackageDraftsResponse:published", + "api/PublishPackageDraftsResponse:publishedCount", + "api/PublishPackageDraftsResponse:rebindError", + "api/PublishPackageDraftsResponse:seedApplied", + "api/PublishPackageDraftsResponse:success", + "api/PublishPackageDraftsResponse:unhiddenApps", + "api/PublishPackageDraftsResponse:unhideError", "api/QueryAdapterConfig:odata", "api/QueryAdapterConfig:operatorMappings", "api/QueryAdapterConfig:rest", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index bb2386a5c9..55955a7f7e 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -730,6 +730,8 @@ "PresignedUrlResponseSchema": "src/api/storage.zod.ts#PresignedUrlResponseSchema (const)", "PublishMetaItemResponse": "src/api/protocol.zod.ts#PublishMetaItemResponse (type)", "PublishMetaItemResponseSchema": "src/api/protocol.zod.ts#PublishMetaItemResponseSchema (const)", + "PublishPackageDraftsResponse": "src/api/protocol.zod.ts#PublishPackageDraftsResponse (type)", + "PublishPackageDraftsResponseSchema": "src/api/protocol.zod.ts#PublishPackageDraftsResponseSchema (const)", "QueryAdapterConfig": "src/api/query-adapter.zod.ts#QueryAdapterConfig (type)", "QueryAdapterConfigParsed": "src/api/query-adapter.zod.ts#QueryAdapterConfigParsed (type)", "QueryAdapterConfigSchema": "src/api/query-adapter.zod.ts#QueryAdapterConfigSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 26b2dcaccd..f8bafc440d 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -302,6 +302,7 @@ "api/PresenceUpdate", "api/PresignedUrlResponse", "api/PublishMetaItemResponse", + "api/PublishPackageDraftsResponse", "api/QueryAdapterConfig", "api/QueryAdapterTarget", "api/QueryOptimizationConfig", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index cafdbb49e9..d9d7e8982e 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -1025,3 +1025,264 @@ describe('RuntimeAuthoringIssueSchema (#4717 — the ONE finding shape)', () => expect(RuntimeAuthoringIssueSchema.safeParse({ ...issue, severity: 'fatal' }).success).toBe(false); }); }); + +import { PublishPackageDraftsResponseSchema } from './protocol.zod'; + +/** + * #9406 — the same suite one door over again, for the BATCH publish door + * `POST /packages/:id/publish-drafts` (Studio's "publish whole app"). + * + * The batch door sat in exactly the undeclared state the single-item publish + * door sat in before #7294: its face lived only as an inline TypeScript return + * type in `@objectstack/metadata-protocol`, so a field added to or dropped + * from the response could not turn any declared-contract gate red — #9343's + * discarded advisories were the measured instance. + * + * Optionality is measured, not assumed (receipts in the PR): the producer pair + * is `ObjectStackProtocolImplementation.publishPackageDrafts` (three return + * sites, all setting the five required keys) plus the REST door's response + * mutations in `packages/runtime/src/domains/packages.ts` (`seedApplied` + * back-fill, `unhiddenApps` / `unhideError` / `rebindError`). The producer + * side is pinned in + * `packages/objectql/src/publish-package-drafts-response-conformance.test.ts` + * and the route mutations in + * `packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts`. + */ +describe('PublishPackageDraftsResponseSchema (#9406 — declares the batch publish response)', () => { + /** A verbatim-shaped capture of a real `publishPackageDrafts` return (happy path). */ + const realResponse = { + success: true, + publishedCount: 2, + failedCount: 0, + published: [ + { type: 'view', name: 'cases', version: 'sha256:7aad99c8d969efb5067fff275fb3e5be7ec90f9cd610d41709fcddbf8c34b1f0' }, + { type: 'flow', name: 'nightly_purge', version: 'sha256:1bad99c8d969efb5067fff275fb3e5be7ec90f9cd610d41709fcddbf8c34b1f1' }, + ], + failed: [], + commitId: 'cmt_01HZX3V9K2', + }; + + it('round-trips a real response without stripping any field', () => { + const parsed = PublishPackageDraftsResponseSchema.parse(realResponse); + expect(Object.keys(parsed).sort()).toEqual(Object.keys(realResponse).sort()); + expect(parsed).toEqual(realResponse); + }); + + it('requires the five always-emitted keys — every producer return site sets them', () => { + for (const missing of ['success', 'publishedCount', 'failedCount', 'published', 'failed'] as const) { + const body: Record = { ...realResponse }; + delete body[missing]; + expect( + PublishPackageDraftsResponseSchema.safeParse(body).success, + `omitting '${missing}' must fail parse`, + ).toBe(false); + } + }); + + it('keeps the counters integers and rejects fractional ones', () => { + expect(PublishPackageDraftsResponseSchema.safeParse({ ...realResponse, publishedCount: 1.5 }).success).toBe(false); + expect(PublishPackageDraftsResponseSchema.safeParse({ ...realResponse, failedCount: 0.5 }).success).toBe(false); + }); + + it('published[] elements carry the ADR-0008 OCC token: version is required per element', () => { + const parsed = PublishPackageDraftsResponseSchema.parse(realResponse); + expect(parsed.published[0]!.version).toBe(realResponse.published[0]!.version); + const lossy = { + ...realResponse, + published: [{ type: 'view', name: 'cases' }], + }; + expect(PublishPackageDraftsResponseSchema.safeParse(lossy).success).toBe(false); + }); + + it('failed[] carries the refusal shape, code optional — BATCH_ABORTED marks non-causal items', () => { + const rolledBack = { + success: false, + publishedCount: 0, + failedCount: 2, + published: [], + failed: [ + { type: 'object', name: 'ticket', error: "[invalid_metadata] object name 'ticket' must carry the package namespace prefix", code: 'PACKAGE_NAMESPACE_PREFIX_REQUIRED' }, + { type: 'view', name: 'cases', error: 'not published — the batch is all-or-nothing (ADR-0067 D2) and object/ticket failed; the transaction rolled back', code: 'BATCH_ABORTED' }, + ], + }; + const parsed = PublishPackageDraftsResponseSchema.parse(rolledBack); + expect(parsed.failed).toEqual(rolledBack.failed); + // `error` is required once an element exists — a failure with no reason + // is the shape this declaration exists to refuse. + expect( + PublishPackageDraftsResponseSchema.safeParse({ + ...rolledBack, + failed: [{ type: 'object', name: 'ticket' }], + }).success, + ).toBe(false); + }); + + it('carries seedApplied — counters optional ONLY for the route-level fallback producer', () => { + // The in-batch producer (`applySeedBodies`) always emits both counters. + const inBatch = PublishPackageDraftsResponseSchema.parse({ + ...realResponse, + seedApplied: { success: true, inserted: 3, updated: 1, errors: [] }, + }); + expect(inBatch.seedApplied).toEqual({ success: true, inserted: 3, updated: 1, errors: [] }); + // The route back-fill's early-failure shape has no counters — measured on + // `applyPublishedSeeds` ("required services unavailable") and the door's + // own catch. The wire face is the union, so the counters are optional. + const routeFallback = PublishPackageDraftsResponseSchema.parse({ + ...realResponse, + seedApplied: { success: false, error: 'seed apply failed' }, + }); + expect(routeFallback.seedApplied).toEqual({ success: false, error: 'seed apply failed' }); + // `success` stays required either way. + expect( + PublishPackageDraftsResponseSchema.safeParse({ ...realResponse, seedApplied: { inserted: 1, updated: 0 } }).success, + ).toBe(false); + }); + + it('carries materializeApplied with the batch\'s per-item failures[], not the single door\'s scalar error', () => { + const parsed = PublishPackageDraftsResponseSchema.parse({ + ...realResponse, + materializeApplied: { + success: false, inserted: 1, updated: 0, + failures: [{ type: 'permission', name: 'sales_reps', error: 'materialize failed' }], + }, + }); + expect(parsed.materializeApplied?.failures).toHaveLength(1); + // `failures` is REQUIRED once the key is present — the aggregate's whole + // point is naming WHICH items never went live (an omitted list would + // read as "none failed" while `success: false` says otherwise). + expect( + PublishPackageDraftsResponseSchema.safeParse({ + ...realResponse, + materializeApplied: { success: true, inserted: 1, updated: 0 }, + }).success, + ).toBe(false); + }); + + it('probes is opaque BY DECLARATION (#9406 ruling): any shape passes through unmodified, unstripped', () => { + // The real `BuildProbeReport` shape of today… + const report = { issues: [], checked: { seeds: 1, views: 2, widgets: 0 } }; + const parsed = PublishPackageDraftsResponseSchema.parse({ ...realResponse, probes: report }); + expect(parsed.probes).toEqual(report); + // …and a future shape this contract deliberately does NOT constrain. If a + // consumer ever needs a field of `probes`, upgrade the declaration on its + // own card instead of relaxing this pin. + expect( + PublishPackageDraftsResponseSchema.safeParse({ ...realResponse, probes: { anything: 'else' } }).success, + ).toBe(true); + }); + + it('declares the route-attached ADR-0045 receipts: unhiddenApps / unhideError / rebindError', () => { + const withFlip = PublishPackageDraftsResponseSchema.parse({ + ...realResponse, + unhiddenApps: ['crm', 'ops'], + }); + expect(withFlip.unhiddenApps).toEqual(['crm', 'ops']); + // #5242's split report: the persisted half rides BESIDE the failure. + const partWay = PublishPackageDraftsResponseSchema.parse({ + ...realResponse, + unhiddenApps: ['crm'], + unhideError: 'visibility flip failed', + }); + expect(partWay.unhiddenApps).toEqual(['crm']); + expect(partWay.unhideError).toBe('visibility flip failed'); + const withRebind = PublishPackageDraftsResponseSchema.parse({ + ...realResponse, + rebindError: 'metadata:reloaded announce failed', + }); + expect(withRebind.rebindError).toBe('metadata:reloaded announce failed'); + }); + + it('leaves every conditional key optional — absent means "did not apply", never "failed"', () => { + const minimal = { + success: false, + publishedCount: 0, + failedCount: 0, + published: [], + failed: [], + }; + for (const key of ['seedApplied', 'materializeApplied', 'probes', 'commitId', 'unhiddenApps', 'unhideError', 'rebindError'] as const) { + expect(minimal).not.toHaveProperty(key); + } + expect(PublishPackageDraftsResponseSchema.safeParse(minimal).success).toBe(true); + }); + + it('does not fabricate any key: parse of a lean body adds nothing (byte-stability half)', () => { + // No `.default()` anywhere — a declaration that invents keys on the + // consumer side would make "absent" unobservable and change what callers + // see relative to the wire bytes. The other half of the byte-stability + // proof (the wire itself is unchanged) lives in the producer-side + // conformance suites: nothing on the serving path parses this schema. + const parsed = PublishPackageDraftsResponseSchema.parse(realResponse) as Record; + expect(Object.keys(parsed).sort()).toEqual(Object.keys(realResponse).sort()); + }); +}); + +/** + * #9406 / #9343 — `advisories` on the batch door's `published[]` elements: the + * same #4463 D3 key the single-item doors carry, riding each element (the + * maintainer ruled against a parallel top-level map on #9343). Same + * conditional-field caveat as both siblings: a strip-nothing conformance gate + * cannot go red for an absent key, so these declaration pins are what make the + * key checkable in both directions. + */ +describe('PublishPackageDraftsResponseSchema published[].advisories (#9343 — #4463 D3 per element)', () => { + const base = { + success: true, + publishedCount: 1, + failedCount: 0, + failed: [], + commitId: 'cmt_01HZX3V9K2', + }; + + /** A verbatim capture of a real finding — `lintFlowPatterns`, warning tier. */ + const advisory = { + rule: 'flow-multi-write-unfiltered', + path: 'flow \'nightly_purge\' · node \'purge\' (delete_record)', + where: 'flow \'nightly_purge\' · node \'purge\' (delete_record)', + message: 'declares `multi: true` with no `filter` key — this is a WHOLE-OBJECT write.', + hint: 'Add a `filter`, or state the whole-object intent explicitly.', + severity: 'warning' as const, + }; + + const el = (extra?: Record) => ({ + type: 'flow', name: 'nightly_purge', + version: 'sha256:7aad99c8d969efb5067fff275fb3e5be7ec90f9cd610d41709fcddbf8c34b1f0', + ...(extra ?? {}), + }); + + it('carries a real advisory through parse on its element, unstripped', () => { + const parsed = PublishPackageDraftsResponseSchema.parse({ + ...base, + published: [el({ advisories: [advisory] })], + }); + expect(parsed.published[0]!.advisories).toEqual([advisory]); + }); + + it('is OPTIONAL per element — absence means "nothing to report", never "the gate did not run"', () => { + const parsed = PublishPackageDraftsResponseSchema.parse({ ...base, published: [el()] }); + expect(parsed.published[0]!.advisories).toBeUndefined(); + expect('advisories' in parsed.published[0]!).toBe(false); + }); + + it('element shape is the ONE declared finding shape — a lossy element is refused, not narrowed', () => { + // `RuntimeAuthoringIssueSchema` by reference, not a local re-declaration — + // the save door's advisories[], the single publish door's, this door's, + // and the 422 issues[] stay one dialect (#4717). + const partial = { rule: 'x', severity: 'warning' }; + expect( + PublishPackageDraftsResponseSchema.safeParse({ + ...base, + published: [el({ advisories: [partial] })], + }).success, + ).toBe(false); + }); + + it('rejects a non-array, so a single issue object cannot masquerade as the list', () => { + expect( + PublishPackageDraftsResponseSchema.safeParse({ + ...base, + published: [el({ advisories: advisory })], + }).success, + ).toBe(false); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index b1af72d5c5..5a3150dac5 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -688,6 +688,206 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ ), })); +/** + * Publish Package Drafts Response — the "publish whole app" door. + * + * Describes the FULL body `POST /api/v1/packages/:id/publish-drafts` answers + * inside the dispatcher's `{ success, data }` envelope (#9406 — the #5745 + * "declared = returned" discipline carried to the batch door, the same move + * #7294 made for the single-item `POST /meta/:type/:name/publish`). Until this + * declaration the batch response was only an inline TypeScript return type in + * `@objectstack/metadata-protocol` — no spec schema, no pin suite, no + * conformance gate — so a field added to or dropped from Studio's "publish + * whole app" response could not turn anything red (#9343's discarded + * advisories were the measured instance). + * + * **The wire face is the helper's return PLUS the route's mutations** — + * measured on the producer pair, not assumed: + * + * - `ObjectStackProtocolImplementation.publishPackageDrafts` builds the base + * object. Its three return sites always set `success` / `publishedCount` / + * `failedCount` / `published` / `failed` (so those five are REQUIRED), and + * attach `seedApplied` / `materializeApplied` / `probes` / `commitId` only + * on the happy path when the corresponding fact exists (so each is + * optional — absence means "did not apply", never "failed"). + * - The REST door (`packages/runtime/src/domains/packages.ts`) then mutates + * that object before `res.json()`: it back-fills `seedApplied` for custom + * protocols that do not self-apply, and attaches the ADR-0045 visibility + * flip receipts `unhiddenApps` / `unhideError` and the `metadata:reloaded` + * announce receipt `rebindError`. Those keys are ON the wire, so they are + * declared here — a schema of the helper's raw return would strip them. + * + * **`success` is the batch verdict, not an HTTP echo**: the producer sets + * `failed.length === 0 && published.length > 0`, and the two refusal returns + * (pre-flight violations; the ADR-0067 D2 all-or-nothing rollback) answer + * `success: false` on a 200 with `publishedCount: 0`, `published: []` and the + * whole batch in `failed[]` (`BATCH_ABORTED` marks the non-causal items). + * + * **`probes` is deliberately OPAQUE** — see its own note below. Do not model + * it here without a consumer-driven card (#9406 ruling). + */ +export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ + success: z.boolean().describe( + 'True only when every pending draft promoted (`failed` empty) AND at ' + + 'least one item published. A pre-flight refusal or an ADR-0067 D2 ' + + 'rollback answers false on a 200 — read `failed[]`, not the HTTP ' + + 'status. It does NOT cover the best-effort receipts below, each of ' + + 'which reports its own `success`.', + ), + publishedCount: z.number().int().describe( + 'Number of drafts promoted to active — `published.length`. 0 on every ' + + 'refusal path (the batch is all-or-nothing, ADR-0067 D2).', + ), + failedCount: z.number().int().describe( + 'Number of items that did not publish — `failed.length`. On a rollback ' + + 'this counts the WHOLE batch: the causal item plus every sibling ' + + 'marked BATCH_ABORTED.', + ), + published: z.array(z.object({ + type: z.string().describe('Metadata type of the promoted draft (canonical singular).'), + name: z.string().describe('Item name of the promoted draft.'), + version: z.string().describe( + 'Content hash of the just-promoted body — the same ADR-0008 ' + + 'optimistic-concurrency token the single-item doors return: echo it ' + + 'back as `If-Match` on the next write to this item. Opaque to ' + + 'callers; currently `sha256:<64 hex chars>`, but the format is not ' + + 'part of this contract.', + ), + advisories: z.array(RuntimeAuthoringIssueSchema).optional().describe( + 'Non-gating findings the #4463 runtime authoring gate raised against ' + + 'THIS draft\'s promotion (#9343 — the same element shape and the ' + + 'same omitted-when-empty discipline as ' + + '`PublishMetaItemResponseSchema.advisories`, riding each element ' + + 'rather than a parallel top-level map). Present ONLY when at least ' + + 'one finding was raised — an empty array is never emitted, so an ' + + 'advisory-free batch\'s response bytes are unchanged. Advisory by ' + + 'construction: every entry is `warning`/`info`, because an `error` ' + + 'finding refuses the promotion and — the batch being all-or-nothing ' + + '— aborts the whole batch as `failed[]` instead; `failed[]` ' + + 'elements never carry this key.', + ), + })).describe( + 'Every draft promoted to active, in publish order. Empty on every ' + + 'refusal path.', + ), + failed: z.array(z.object({ + type: z.string().describe('Metadata type of the item that did not publish.'), + name: z.string().describe('Item name.'), + error: z.string().describe( + 'What refused it. On a rollback, the causal item carries its real ' + + 'error and every sibling carries the all-or-nothing explanation.', + ), + code: z.string().optional().describe( + 'Machine code for the refusal class (SCREAMING_SNAKE, ADR-0112 ' + + 'vocabulary) — e.g. a pre-flight violation code, or BATCH_ABORTED ' + + 'on the non-causal items of a rolled-back batch.', + ), + })).describe( + 'Items that did not publish. Because the batch is all-or-nothing ' + + '(ADR-0067 D2), a non-empty list means NOTHING landed: ' + + '`published: []`, `publishedCount: 0`.', + ), + seedApplied: z.object({ + success: z.boolean().describe( + 'False when the seed rows did not fully land. The publish itself ' + + 'still succeeded — check this rather than assuming data went live.', + ), + inserted: z.number().int().optional().describe( + 'Rows created by the externalId-keyed upsert. Optional ONLY because ' + + 'the route-level fallback producer (custom protocols that do not ' + + 'self-apply) reports early failures without counters; the in-batch ' + + 'producer always emits both counters.', + ), + updated: z.number().int().optional().describe( + 'Rows updated by the externalId-keyed upsert. Same optionality ' + + 'rationale as `inserted`.', + ), + error: z.string().optional().describe( + 'Single failure message, present when the apply failed before the ' + + 'loader ran (including "no readable seed bodies").', + ), + errors: z.array(z.unknown()).optional().describe( + 'Per-record failures reported by the seed loader, plus any seed-body ' + + 'read failures. May be present and empty on a clean load.', + ), + }).optional().describe( + 'Aggregate outcome of materializing EVERY published `seed` body in one ' + + 'multi-pass loader run (cross-seed references need the whole set). ' + + 'Present ONLY when the batch published at least one seed. Two ' + + 'producers, one key: the batch itself self-applies ' + + '(`applySeedBodies`), and the REST door back-fills the same key for ' + + 'custom protocols that do not — never both (an externalId-less seed ' + + 'would double-insert). Best-effort: a seed problem is surfaced here, ' + + 'never thrown.', + ), + materializeApplied: z.object({ + success: z.boolean().describe('False when any item\'s materializer failed; the publish still succeeded.'), + inserted: z.number().int().describe('Data-plane rows created across the batch.'), + updated: z.number().int().describe('Data-plane rows updated across the batch.'), + failures: z.array(z.object({ + type: z.string().describe('Metadata type of the item whose projection did not land.'), + name: z.string().describe('Item name.'), + error: z.string().describe('Why the projection did not land.'), + })).describe( + 'Each item whose data-plane projection did NOT land — named ' + + 'per-item, unlike the single-item door\'s scalar `error`, because ' + + 'one aggregate boolean over N items would hide WHICH ones never ' + + 'went live.', + ), + }).optional().describe( + 'ADR-0086 P2 — aggregate result of publish-time materializers across ' + + 'the batch (e.g. `permission` → `sys_permission_set`), including ' + + 'side-effect failures surfaced by the per-item effects loop. Present ' + + 'ONLY when at least one published item had a registered materializer ' + + 'or a side-effect failure. Best-effort, same contract as ' + + '`seedApplied`.', + ), + // #9406 ruling: `probes` (`BuildProbeReport`) gets a DELIBERATELY OPAQUE + // passthrough declaration — the key is declared so the face is complete and + // nothing strips it, but its inner shape is NOT modeled here. It upgrades to + // a modeled Zod schema only when a consumer needs a field of it (maintainer, + // 2026-08-18: "Do not model it speculatively"). The TS shape lives on + // `packages/metadata-protocol/src/build-probes.ts` (`BuildProbeReport`: + // `issues[]` + per-plane `checked` counters). + probes: z.unknown().optional().describe( + 'ADR-0038 L3 post-publish runtime probe report — one real read per ' + + 'published artifact (seeded objects have rows, views are readable, ' + + 'widget dataset selections execute). DELIBERATELY OPAQUE in this ' + + 'contract (#9406): the key is declared and carried through verbatim, ' + + 'but its inner shape is intentionally not modeled until a consumer ' + + 'needs a field of it. Present only when something was publishable; ' + + 'probes never fail the publish.', + ), + commitId: z.string().optional().describe( + 'ADR-0067 — id of the commit this publish recorded. Absent when ' + + 'nothing published.', + ), + unhiddenApps: z.array(z.string()).optional().describe( + 'ADR-0045 §3 — names of the apps whose `_unpublished` gate this publish ' + + 'cleared (publish = live AND visible; a materialized additive build ' + + 'has no drafts, only this flip). Attached by the REST door, not the ' + + 'protocol helper. Present ONLY when at least one app flipped — on a ' + + 'mid-loop failure it names the apps that DID persist, beside ' + + '`unhideError` (#5242\'s split report). Spelling is a permanent wire ' + + 'contract (see the door\'s #6955 note).', + ), + unhideError: z.string().optional().describe( + 'Present when the ADR-0045 visibility flip failed (wholly or partway): ' + + 'the drafts ARE published, but apps still stored `_unpublished: true` ' + + 'stay externally unobservable. Client-facing text only — undeclared ' + + 'driver text is withheld per ADR-0112 (#8516); the full cause is in ' + + 'the server log. The route is idempotent: re-run it once the cause is ' + + 'resolved.', + ), + rebindError: z.string().optional().describe( + 'Present when the post-publish `metadata:reloaded` announce failed: ' + + 'everything is published and stored, but boot-cached consumers keep ' + + 'the pre-publish view until re-run or restart (a newly published ' + + 'record-triggered flow does not bind its trigger). Client-facing text ' + + 'only, same ADR-0112 withhold as `unhideError` (#8516).', + ), +})); + /** * Delete Metadata Item Request * Removes a customization overlay row from sys_metadata (ADR-0005). @@ -1845,6 +2045,12 @@ export type RuntimeAuthoringIssue = z.input; export type SaveMetaItemRequest = z.input; export type SaveMetaItemResponse = z.input; export type PublishMetaItemResponse = z.input; +/** + * The batch publish door's response — `POST /packages/:id/publish-drafts` + * (#9406). `probes` is `unknown` by declaration, not by omission: the #9406 + * ruling stages it opaque until a consumer needs a field of it. + */ +export type PublishPackageDraftsResponse = z.input; export type DeleteMetaItemRequest = z.input; export type DeleteMetaItemResponse = z.input; export type GetMetaItemCachedRequest = z.input; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 689c8a536e..4a05640cbe 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -264,7 +264,7 @@ import type * as M167 from './ui/view.zod.js'; import type * as M170 from './ui/component.zod.js'; // --------------------------------------------------------------------------- -// 838 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 839 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -473,6 +473,7 @@ export type Iso833 = Assert, z.infer< typeof M28.SaveMetaItemRequestSchema > >>; export type Iso136 = Assert, z.infer< typeof M28.SaveMetaItemResponseSchema > >>; export type Iso836 = Assert, z.infer< typeof M28.PublishMetaItemResponseSchema > >>; +export type Iso852 = Assert, z.infer< typeof M28.PublishPackageDraftsResponseSchema > >>; export type Iso837 = Assert, z.infer< typeof M28.RuntimeAuthoringIssueSchema > >>; export type Iso137 = Assert, z.infer< typeof M28.DeleteMetaItemRequestSchema > >>; export type Iso138 = Assert, z.infer< typeof M28.DeleteMetaItemResponseSchema > >>; @@ -1655,7 +1656,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 838 isomorphic pins', () => { + it('still declares all 839 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -1893,9 +1894,21 @@ describe('ADR-0122 type-alias convention', () => { // declaration → fit to the queried records", so a spec-side default // zoom/center would read as a declared camera and defeat the fit. Pinned // as `Iso851`. + // + // 838 -> 839 is #9406 — the batch publish door's + // `PublishPackageDraftsResponseSchema` (the #7294 move one door over, + // `Iso836`). Isomorphism MEASURED, not assumed: booleans, strings, + // `z.number().int()`s, arrays of plain objects, one referenced + // `RuntimeAuthoringIssueSchema` (itself pinned, `Iso837`) and one + // `z.unknown()` (`probes`, deliberately opaque by the #9406 ruling) — no + // `.default()`, `.transform()`, `.catch()` or `.pipe()` anywhere. The + // absence of `.default()` is load-bearing beyond this pin: the door's + // byte-stability promise (an advisory-free publish response unchanged by + // the declaration) relies on parse fabricating nothing. Pinned as + // `Iso852`, the next free id. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert