diff --git a/.changeset/runtime-gate-advisories-on-save-response.md b/.changeset/runtime-gate-advisories-on-save-response.md new file mode 100644 index 0000000000..d827bfd659 --- /dev/null +++ b/.changeset/runtime-gate-advisories-on-save-response.md @@ -0,0 +1,60 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +--- + +feat(spec,metadata-protocol): the runtime authoring gate's advisory findings reach the save response (#4717) + +#4463 put the shared author-time rule registry on the runtime write path — the +fourth door, and for a Studio tenant or an MCP/AI author the ONLY one, because a +`sys_metadata` overlay row is not in the CLI's config file and there is no +`os lint` to run against it. It gated on `error` findings only. The rest — the +advisory half — were produced, walked into a `console.warn` deduped once per +process per `type|name|rule|path`, and then went out of scope. #4715 named that +honestly when it shipped: running a rule and discarding its conclusion is a +smaller version of the hole the gate was built to close. + +That case is reachable today, not theoretical. A flow whose only defect is a +`delete_record` node declaring `multi: true` with no `filter` yields +`errors = 0 / advisories = 1`: the write **succeeds**, the row persists, the +flow registers, and the author never learns that their nightly sweep deletes +every row of the object on every run. + +**What changed** + +- `SaveMetaItemResponseSchema` declares an OPTIONAL `advisories` array, whose + element is the newly-declared `RuntimeAuthoringIssueSchema` — the SAME + `rule` / `path` / `where` / `message` / `hint` / `severity` shape the 422 + `invalid_metadata` envelope already carries (#4463 D3, "reuse the Zod + envelope"). It is declared once: `@objectstack/metadata-protocol` re-exports + it as its `RuntimeAuthoringIssue` instead of keeping a second hand-written + interface for the same six keys, so the refusal and the success channel + cannot drift into two dialects. +- `evaluateRuntimeAuthoringGate` returns a `RuntimeAuthoringVerdict` + (`{ error, advisories }`) instead of `Error | null`. This is an ADDED return + channel, not a threaded value: the success path previously returned `null` and + had nowhere to put a verdict at all. +- `saveMetaItem` attaches the advisories to its success response. + +**Additive and conditional.** The key is emitted ONLY when at least one advisory +was raised — never as `[]` — so a clean save's response bytes are byte-for-byte +what they were before, and a caller that ignores the field behaves exactly as +today. Absence means "nothing to report", never "the gate did not run". + +**`rulesRun` is deliberately NOT on the response.** The gate appends its own +`PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING` when the type is `flow`, so not +every id it would list resolves in the lint registry; exposing the array would +need the declaration to say the ids are *gate* ids. A field can be added later, +not removed. + +**⚠️ Save door only — the asymmetry is deliberate, not an oversight.** The gate +runs on BOTH write doors: `saveMetaItem` and the draft→active promotion, on +purpose, so `?mode=draft` followed by publish is not a bypass (#4463 D1). +Studio's designer uses draft-then-publish on every edit, so the publish door is +the dominant Studio flow and it does **not** carry this field yet. That door's +own response contract only just landed (#7294); carrying the advisories over is +tracked separately rather than bundled here, so this change stays one optional +field on one already-declared envelope. + +Rendering the findings in Studio is the objectui half of #4717 and is queued in +that repo behind this change. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 551af7ea8b..9ac5238a63 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, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, PublishMetaItemResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +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'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -1347,6 +1347,22 @@ List packages response | **id** | `string` | ✅ | The rejected action id | +--- + +## RuntimeAuthoringIssue + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **rule** | `string` | ✅ | Stable diagnostic rule id (`flow-multi-write-unfiltered`, `approval-expression-invalid`, …). Machine-readable and stable across releases — the key a renderer groups or suppresses by. | +| **path** | `string` | ✅ | Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. | +| **where** | `string` | ✅ | Human-readable location — `flow "leave_approval" · node "approve"`. Prose for a person; use `path` for anything mechanical. | +| **message** | `string` | ✅ | What is wrong, in the rule author's own words. | +| **hint** | `string` | ✅ | How to fix it. | +| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | How the gate treated this finding. `error` means the write was REFUSED (these appear on the 422, never on a 2xx); `warning` / `info` are advisory — the write succeeded and the finding is FYI. | + + --- ## SaveMetaItemRequest @@ -1373,6 +1389,7 @@ List packages response | **seq** | `integer` | ✅ | Monotonic sequence number of the metadata event this write appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | | **state** | `Enum<'draft' \| 'active'>` | ✅ | Lifecycle the body was written into: "draft" when the request asked for draft mode (`?mode=draft`), otherwise "active" (published and live). A draft is staged only — it is not served to the runtime until published. | | **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `permission` → `sys_permission_set`). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. | +| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the #4463 runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (#4717, closing #4463 D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap #4463 exists to close. NOTE the door asymmetry — `POST /meta/:type/:name/publish` does not carry this field yet (its declaration landed separately as #7294); the gate runs on both doors, only the save door reports. | | **message** | `string` | optional | | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 654c4a6674..ab31d42a36 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 — 1576 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1577 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 | 411 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 28 | 412 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 30 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 292 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 147 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1576** | 14 protocol modules | +| **Total** | **200** | **1577** | 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, 411 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **28 pages, 412 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`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `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 1ac17f5250..639c2d242c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -264,7 +264,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 397 | +| `api/` | 398 | | `cloud/` | 83 | | `identity/` | 33 | | `integration/` | 10 | diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index cd973cf766..85e5d05b8d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -12,6 +12,7 @@ import { readEnvWithDeprecation, resolveTenancyPosture } from '@objectstack/type import { postureEnforcesWall } from '@objectstack/spec/security'; import type { MetadataHostEngine } from './host-engine.js'; import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js'; +import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; // [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the // ADR-0120 D4 reporting that replaced this file's empty `catch` blocks. import { ensureMetadataOverlayIndexes } from './migrations/overlay-index.js'; @@ -2834,6 +2835,16 @@ export class ObjectStackProtocolImplementation implements * and passed as arguments, so `evaluateRuntimeAuthoringGate` stays a pure * function of its inputs and a test can drive both postures without * mutating the process. + * + * [#4717] Throws on the gating half, RETURNS the advisory half. Advisories + * do not block anything, so the only honest place for them is the 2xx the + * write earns — `saveMetaItem` attaches them to its response, and the only + * other caller (the draft→active promotion in `publishMetaItem`) simply + * ignores the value, which is why adding this channel could not change what + * either door does. + * Returns an empty array on every early return: no rules ran, so there is + * nothing to report, and "clean" is told apart from "nothing ran" by the + * gate's own `rulesRun`, not by this. */ private assertRuntimeAuthoringRules(evt: { type: string; name: string; state: 'draft' | 'active'; body: unknown; source?: string; @@ -2843,7 +2854,7 @@ export class ObjectStackProtocolImplementation implements * which is one limb of the #6285 refusal combination. */ organizationId?: string | null; - }): void { + }): RuntimeAuthoringIssue[] { // [#6710] The ADR-0005 carve-out, now DECLARED instead of inferred. // // This line used to read `if (this.environmentId === undefined) @@ -2876,8 +2887,8 @@ export class ObjectStackProtocolImplementation implements // // `environmentId` keeps every other job it has, including the #3050 // authoring gate's own scope check below. - if (this.authoringChannel === 'package-author') return; - if (evt.state !== 'active') return; + if (this.authoringChannel === 'package-author') return []; + if (evt.state !== 'active') return []; // `os migrate meta --stored --apply` rewrites rows that ALREADY EXIST // into the current dialect. It is not an author publishing anything — // it is the platform healing its own storage — and #4463 D4 is explicit @@ -2892,7 +2903,7 @@ export class ObjectStackProtocolImplementation implements // no caller can spell its way past the gate. `duplicatePackage` is // deliberately NOT here — it mints brand-new rows under new names, and // a copy of a broken flow is a new broken flow. - if (evt.source === 'migrate-stored') return; + if (evt.source === 'migrate-stored') return []; const singular = PLURAL_TO_SINGULAR[evt.type] ?? evt.type; // Resolution context. Best-effort: a host without a registry (a @@ -2908,7 +2919,7 @@ export class ObjectStackProtocolImplementation implements objects = []; } - const err = evaluateRuntimeAuthoringGate({ + const verdict = evaluateRuntimeAuthoringGate({ type: singular, name: evt.name, state: evt.state, @@ -2917,7 +2928,8 @@ export class ObjectStackProtocolImplementation implements ...(evt.organizationId !== undefined ? { organizationId: evt.organizationId } : {}), orgWallEnforced: this.orgWallEnforced(), }); - if (err) throw err; + if (verdict.error) throw verdict.error; + return verdict.advisories; } /** @@ -9368,7 +9380,13 @@ export class ObjectStackProtocolImplementation implements // immediately after the schema check because a rule reads a body the // schema already accepted — a Zod failure is the more basic verdict and // must be the one the author sees first. - this.assertRuntimeAuthoringRules({ + // + // [#4717] The advisory half of D3 starts its journey here. `errors` is + // thrown above as the 422; `advisories` never blocks anything, so it is + // captured and rides the 2xx this write is about to earn. Held in a + // local rather than on `this`: the gate is per-write and two concurrent + // saves must not read each other's findings. + const runtimeAdvisories = this.assertRuntimeAuthoringRules({ type: request.type, name: request.name, state: mode === 'draft' ? 'draft' : 'active', @@ -9579,6 +9597,24 @@ export class ObjectStackProtocolImplementation implements version: result.version, seq: result.seq, ...(projectionApplied ? { projectionApplied } : {}), + // [#4717] #4463 D3's advisory half, finally on the response. + // + // CONDITIONAL on purpose, and the condition is the contract: + // the key is omitted — never `[]` — when the gate had nothing + // to say, so a clean save's response bytes are exactly what + // they were before this field existed and no existing caller + // sees a new key. `SaveMetaItemResponseSchema.advisories` is + // declared optional for that reason, and the conformance suite + // pins BOTH directions (present with findings, absent without), + // because an optional key is precisely what a conformance gate + // built on "nothing was stripped" cannot notice on its own. + // + // Save door only. The gate also runs on the draft→active + // promotion (#4463 D1, so `?mode=draft` + publish is not a + // bypass) and that door does NOT carry this field yet — its own + // response contract only just landed as #7294. The asymmetry is + // deliberate and stated in the changeset. + ...(runtimeAdvisories.length > 0 ? { advisories: runtimeAdvisories } : {}), // #5745 — the literal union, not `string`. An object-literal // property widens a two-literal ternary to `string`, which made // this method fail to satisfy `MetadataProtocol.saveMetaItem` diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index a922f33965..bc11e3a093 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -57,21 +57,20 @@ import { runRuntimeAuthoringRules, type AuthoringFinding } from '@objectstack/li // may only reach that package through its kernel-safe `/runtime` entry (the // wiring guard's third invariant), and `walkFlowNodes` is not on it. import { FLOW_REGION_SLOTS_BY_TYPE } from '@objectstack/spec/automation'; +import type { RuntimeAuthoringIssue } from '@objectstack/spec/api'; -/** The structured issue shape a 422 carries — D3's "reuse the Zod envelope". */ -export interface RuntimeAuthoringIssue { - /** Stable diagnostic rule id (`approval-expression-invalid`, …). */ - rule: string; - /** Config path inside the submitted body. */ - path: string; - /** Human-readable location (`flow "leave_approval" · node "approve"`). */ - where: string; - /** What is wrong. */ - message: string; - /** How to fix it. */ - hint: string; - severity: 'error' | 'warning' | 'info'; -} +/** + * The structured issue shape a 422 carries — D3's "reuse the Zod envelope". + * + * [#4717] Declared ONCE, in `packages/spec` as `RuntimeAuthoringIssueSchema`, + * and re-exported here under the name this package has always used. It became a + * spec declaration the moment D3's other half landed: the advisory findings ride + * `SaveMetaItemResponseSchema.advisories`, which is a public wire contract, and + * a hand-written interface next to a Zod schema for the same six keys is exactly + * the two-dialect drift Prime Directive #1 exists to prevent. The name and the + * import path callers use are unchanged. + */ +export type { RuntimeAuthoringIssue }; /** * The escape hatch (#4463 D4). @@ -345,15 +344,51 @@ const toIssue = (f: AuthoringFinding): RuntimeAuthoringIssue => ({ }); /** - * Judge an about-to-be-published metadata body and return the `Error` the - * caller must throw, or `null` to allow the write. + * The gate's whole verdict on one write — both severities, in one value. + * + * [#4717] This type IS the change #4717 asked for. Until it existed the + * function was typed `=> Error | null`, so the success path had no return + * channel at all: the advisory findings the rules had already produced were + * walked into a deduped `console.warn` and went out of scope. That is the same + * "ran the rule, discarded the verdict" shape #4463 was filed to close, one + * notch quieter — and it was unreachable by exactly the authors the gate exists + * for, since a Studio tenant or an MCP/AI author never sees server stdout. + * + * So this is an ADDED channel, not a threaded value, and it is deliberately a + * record rather than a widened `Error | Issue[] | null` union: both halves of + * the verdict are always answered, and a caller cannot read one while forgetting + * the other exists. + */ +export interface RuntimeAuthoringVerdict { + /** + * The 422 the caller MUST throw, or `null` when the write may proceed. + * Exactly the value this function used to return on its own. + */ + error: Error | null; + /** + * The non-gating findings — `severity` `warning` or `info`. Always an + * array, possibly empty; `saveMetaItem` puts it on the response under + * `advisories` and omits the key entirely when it is empty, so a clean save + * is byte-identical to before (`SaveMetaItemResponseSchema`, #4717). + * + * Populated alongside a non-null `error` too, but that combination never + * reaches a response: the caller throws, and a 422 carries its own + * `issues[]`. Nothing here is a substitute for reading `error`. + */ + advisories: RuntimeAuthoringIssue[]; +} + +/** + * Judge an about-to-be-published metadata body and return BOTH halves of the + * verdict: the `Error` the caller must throw (or `null` to allow the write), + * and the advisory findings that do not block it. * * Returning the error rather than throwing it mirrors * {@link ObjectStackProtocolImplementation.assertLockAllowsWrite}: the caller * owns the audit trail and the throw site. * * @param args.state Lifecycle the body is being written into. Anything but - * `'active'` returns `null` immediately (D1). + * `'active'` returns an empty verdict immediately (D1). */ export function evaluateRuntimeAuthoringGate(args: { /** Singular metadata type (`flow`, …). */ @@ -384,9 +419,10 @@ export function evaluateRuntimeAuthoringGate(args: { * every call site that can know the answer states it. */ orgWallEnforced?: boolean; -}): Error | null { +}): RuntimeAuthoringVerdict { // D1 — drafts are never gated. Publishing one runs this same function. - if (args.state !== 'active') return null; + // No rules ran, so there is nothing to report on either half. + if (args.state !== 'active') return { error: null, advisories: [] }; const result = runRuntimeAuthoringRules({ type: args.type, @@ -410,10 +446,14 @@ export function evaluateRuntimeAuthoringGate(args: { orgWallEnforced: args.orgWallEnforced === true, }); - // P1 gates on `error` only. Advisories are surfaced as a deduped log rather - // than dropped — running a rule and discarding its verdict is the same - // "declared ≠ enforced" shape this gate exists to close, one notch quieter. - // Putting them on the response (and rendering them in Studio) is P2. + // [#4717] The advisory half of D3, now with somewhere to go. The deduped + // log below is KEPT — it is the operator's channel and costs one Set lookup + // — but it is no longer the only one: these travel back to the caller in + // `advisories` and `saveMetaItem` puts them on the 2xx response, which is + // the channel the Studio / MCP / AI author this gate exists for can + // actually read. + const advisories = result.advisories.map(toIssue); + for (const advisory of result.advisories) { const key = `${args.type}|${args.name}|${advisory.rule}|${advisory.path}`; if (_advisoryWarned.has(key)) continue; @@ -424,7 +464,7 @@ export function evaluateRuntimeAuthoringGate(args: { ); } - if (result.errors.length === 0 && localIssues.length === 0) return null; + if (result.errors.length === 0 && localIssues.length === 0) return { error: null, advisories }; const issues = [...result.errors.map(toIssue), ...localIssues]; const summary = issues @@ -450,7 +490,13 @@ export function evaluateRuntimeAuthoringGate(args: { + `The rules that ran: ${rulesRun.join(', ')}. Unset the variable once the metadata is fixed; ` + `the runtime will execute this body as published.`, ); - return null; + // [#4717] The hatch converts a REFUSAL into a log; it does not promote + // the gating findings into the advisory channel. `advisories` means + // "did not block this write", and a finding that only failed to block + // because an operator set a migration flag is not that — it is in the + // un-deduped refusal log above, in full, every time. Widening this to + // carry them would put `severity: 'error'` entries on a 2xx. + return { error: null, advisories }; } const err = new Error( @@ -463,5 +509,7 @@ export function evaluateRuntimeAuthoringGate(args: { // "nothing ran" without guessing (route 3 of the surface-ownership rules: // absence must be loud). (err as any).rulesRun = rulesRun; - return err; + // The caller throws `error`; `advisories` rides along for symmetry and is + // dropped with the response that never happens. + return { error: err, advisories }; } diff --git a/packages/objectql/src/save-meta-response-conformance.test.ts b/packages/objectql/src/save-meta-response-conformance.test.ts index e3d714fb6a..66ce32dd3d 100644 --- a/packages/objectql/src/save-meta-response-conformance.test.ts +++ b/packages/objectql/src/save-meta-response-conformance.test.ts @@ -19,6 +19,25 @@ * specific, quiet way: `safeParse` SUCCEEDED and `version` / `seq` / `state` * were dropped from the parsed result, so the "stripped keys" set was * non-empty. That is the direction it must never drift back to. + * + * ## #4717 — the CONDITIONAL field, and why it needs its own cases + * + * `advisories` (the #4463 D3 advisory findings, now on the 2xx) is emitted only + * when the runtime authoring gate actually raised one. That makes it invisible + * to the five cases above: they all save a clean `view`, so the key is absent + * and `strippedKeys` is `[]` whether or not the declaration exists. Measured, + * not reasoned — see the PR's reverse-verification table. So the two facts have + * to be asserted directly and in both directions: + * + * 1. a save that RAISES an advisory carries it through the declared parse + * (red if the spec field is dropped or either side renames it), and + * 2. a save that raises none does not grow the key at all — the property a + * well-meaning `advisories: []` refactor breaks while every other + * assertion in this file stays green. + * + * The advisory-raising case drives the REAL `runRuntimeAuthoringRules` through + * the real `saveMetaItem`, never a stand-in: a double that produced findings + * more freely than the shipped rules would pin a contract nothing serves. */ import { describe, it, expect } from 'vitest'; import type { ServiceObject } from '@objectstack/spec/data'; @@ -203,3 +222,190 @@ describe('saveMetaItem response conforms to SaveMetaItemResponseSchema (#5745)', ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); }); }); + +/** + * #4717 — the conditional `advisories` field, both directions. + * + * These drive the SHIPPED rule registry (`@objectstack/lint`'s + * `AUTHORING_RULES`, filtered to the runtime surface) through the shipped + * `saveMetaItem`. Nothing here is stubbed: the finding below is produced by + * `lintFlowPatterns`, the same rule `os lint` runs, reached through the same + * `evaluateRuntimeAuthoringGate` a Studio save reaches. + */ +describe('saveMetaItem carries the runtime authoring gate\'s advisories (#4717 — #4463 D3)', () => { + /** + * The reachable success-with-advisories case, verbatim from the #4717 + * measurement: a nightly sweep whose ONLY defect is a `delete_record` node + * declaring `multi: true` with no `filter`. `lintFlowPatterns` raises + * `flow-multi-write-unfiltered` at `severity: 'warning'`, so the gate's + * `errors` set is empty, the write is NOT refused, and before this change + * the finding was `console.warn`ed once per process and never seen by the + * author who made it. + * + * `runAs: 'system'` is load-bearing, not decoration: without it + * `flow-runas-unscoped` fires at `severity: 'error'` and the save becomes a + * 422 — a refusal wearing an advisory's clothes, which would make this case + * pass for the wrong reason. + */ + 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' }], + }); + + /** The same flow with the bulk write bounded — no finding of any severity. */ + const cleanFlow = () => { + const flow = advisoryFlow(); + (flow.nodes[1] as any).config.filter = [{ field: 'created_at', operator: 'lt', value: '2020-01-01' }]; + return flow; + }; + + it('a save whose only defect is advisory succeeds AND reports the finding', async () => { + const p = await makeProtocol(); + const raw: any = await p.saveMetaItem({ + // Env-wide, not org-scoped: the registry declares + // `allowOrgOverride: false` for `flow`, so an org-scoped write is + // refused 403 NOT_OVERRIDABLE long before the authoring gate runs. + type: 'flow', name: 'nightly_purge', item: advisoryFlow(), + }); + + // The write succeeded — this rides a 2xx, which is the whole point of + // the advisory half. (`saveMetaItem` throws on the gated half, so + // reaching this line at all is the "still a 200" assertion; the REST + // route maps a clean return to 200 and a throw to its `status`.) + expect(raw.success).toBe(true); + expect(raw.state).toBe('active'); + + // The finding reached the caller, with the id and severity the rule + // emits. Asserting the RULE ID rather than just a non-empty array: an + // array of the wrong findings is a different defect from an empty one. + expect(raw.advisories).toHaveLength(1); + expect(raw.advisories[0].rule).toBe('flow-multi-write-unfiltered'); + expect(raw.advisories[0].severity).toBe('warning'); + expect(raw.advisories[0].where).toContain('nightly_purge'); + + // …and it survives the DECLARED parse. This is the assertion that goes + // red if `SaveMetaItemResponseSchema.advisories` is removed, or if + // either side renames the key: a plain `z.object` STRIPS what it does + // not declare, so the field would vanish silently rather than fail. + expect(strippedKeys(raw)).toEqual([]); + const parsed = SaveMetaItemResponseSchema.parse(raw); + expect(parsed.advisories).toEqual(raw.advisories); + + // Every element key the declaration promises is really carried — a + // narrower element schema would strip inside the array without + // changing its length. + expect(Object.keys(parsed.advisories![0]!).sort()) + .toEqual(['hint', 'message', 'path', 'rule', 'severity', 'where']); + }); + + /** + * The constraint most likely to be broken by a well-meaning refactor: + * "zero advisories must not change the response bytes" (the #4717 ruling, + * point 2). Emitting `advisories: []` would satisfy every OTHER assertion + * in this file — the spec declares the key, so nothing is stripped and + * `strippedKeys` stays `[]` — which is exactly why this has to be pinned + * on the raw object's KEY SET and not through the parse. + */ + it('zero advisories changes nothing: the key is absent, not empty', async () => { + const p = await makeProtocol(); + const raw: any = await p.saveMetaItem({ + type: 'flow', name: 'bounded_purge', item: cleanFlow(), + }); + + expect(raw.success).toBe(true); + expect('advisories' in raw).toBe(false); + expect(raw.advisories).toBeUndefined(); + // Byte-for-byte: the serialized response of a clean flow save carries + // no trace of the field. `JSON.stringify` is the wire, and the wire is + // the promise being made to existing callers. + expect(JSON.stringify(raw)).not.toContain('advisories'); + expect(SaveMetaItemResponseSchema.safeParse(raw).success).toBe(true); + }); + + /** + * D1 — a draft save is never gated, so it can never report an advisory + * either. Same body as the first case; the difference is `mode: 'draft'`. + */ + it('a draft save runs no rules, so it reports no advisories', async () => { + const p = await makeProtocol(); + const raw: any = await p.saveMetaItem({ + type: 'flow', name: 'nightly_purge', + item: advisoryFlow(), mode: 'draft', + }); + + expect(raw.state).toBe('draft'); + expect('advisories' in raw).toBe(false); + expect(strippedKeys(raw)).toEqual([]); + }); + + /** + * The gated half is unchanged, and it is asserted on its ENVELOPE — `code` + * AND `status` — not on the bare fact of a throw. A `toThrow()` here would + * be permanently green: this body already threw before #4717, so only the + * envelope can tell the refusal apart from any other failure, and the + * refusal is what must NOT have moved when the success path grew a channel. + */ + it('the gating half still refuses with the 422 envelope, unchanged', async () => { + const p = await makeProtocol(); + const brokenApproval = { + name: 'leave_approval', + label: 'Leave Approval', + type: 'autolaunched', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'approve', + type: 'approval', + label: 'Approve', + config: { approvers: [{ type: 'expression', value: 'record.owner ==' }] }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'approve' }], + }; + + await expect( + p.saveMetaItem({ + type: 'flow', name: 'leave_approval', item: brokenApproval, + }), + ).rejects.toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + }); + + /** + * ⚠️ GUARD, NOT EVIDENCE — green in BOTH directions, and labelled so on + * purpose (the #4717 dispatch asked for this variant to be MEASURED rather + * than reasoned about). + * + * The three pre-existing conformance cases save clean `view` bodies, for + * which the gate raises nothing, so the conditional key is absent and + * `strippedKeys` is `[]` whether or not the spec declares `advisories`. + * This case states that fact as an assertion so the next reader does not + * mistake those cases' green for coverage of the new field: the existing + * gate CANNOT go red to report a missing or misspelled declaration, which + * is precisely why the two directional cases above exist. + */ + it('GUARD (green either way): a clean view save is untouched by the new field', async () => { + const p = await makeProtocol(); + const raw: any = await p.saveMetaItem({ + type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'), + }); + + expect('advisories' in raw).toBe(false); + expect(strippedKeys(raw)).toEqual([]); + expect(Object.keys(raw).sort()).toEqual(['message', 'seq', 'state', 'success', 'version']); + }); +}); diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 1c5a7d96a3..788ec8a9e0 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -831,6 +831,8 @@ "RouterConfig (type)", "RouterConfigParsed (type)", "RouterConfigSchema (const)", + "RuntimeAuthoringIssue (type)", + "RuntimeAuthoringIssueSchema (const)", "SERVICE_SELF_INFO_KEY (const)", "SaveMetaItemRequest (type)", "SaveMetaItemRequestSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index a366006bd8..e52c34ea75 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1444,9 +1444,16 @@ "api/RouterConfig:cors", "api/RouterConfig:mounts", "api/RouterConfig:staticMounts", + "api/RuntimeAuthoringIssue:hint", + "api/RuntimeAuthoringIssue:message", + "api/RuntimeAuthoringIssue:path", + "api/RuntimeAuthoringIssue:rule", + "api/RuntimeAuthoringIssue:severity", + "api/RuntimeAuthoringIssue:where", "api/SaveMetaItemRequest:item", "api/SaveMetaItemRequest:name", "api/SaveMetaItemRequest:type", + "api/SaveMetaItemResponse:advisories", "api/SaveMetaItemResponse:message", "api/SaveMetaItemResponse:projectionApplied", "api/SaveMetaItemResponse:seq", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 1232505728..305dd09beb 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -831,6 +831,8 @@ "RouterConfig": "src/api/router.zod.ts#RouterConfig (type)", "RouterConfigParsed": "src/api/router.zod.ts#RouterConfigParsed (type)", "RouterConfigSchema": "src/api/router.zod.ts#RouterConfigSchema (const)", + "RuntimeAuthoringIssue": "src/api/protocol.zod.ts#RuntimeAuthoringIssue (type)", + "RuntimeAuthoringIssueSchema": "src/api/protocol.zod.ts#RuntimeAuthoringIssueSchema (const)", "SERVICE_SELF_INFO_KEY": "src/api/discovery.zod.ts#SERVICE_SELF_INFO_KEY (const)", "SaveMetaItemRequest": "src/api/protocol.zod.ts#SaveMetaItemRequest (type)", "SaveMetaItemRequestSchema": "src/api/protocol.zod.ts#SaveMetaItemRequestSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index a5e53a3ac0..c86259f937 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -345,6 +345,7 @@ "api/RouteHealthEntry", "api/RouteHealthReport", "api/RouterConfig", + "api/RuntimeAuthoringIssue", "api/SaveMetaItemRequest", "api/SaveMetaItemResponse", "api/ScheduleExportRequest", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 36b68c59eb..e62a089e74 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -855,3 +855,105 @@ describe('PublishMetaItemResponseSchema (#7294 — declares the full publish res expect(PublishMetaItemResponseSchema.safeParse(realResponse).success).toBe(true); }); }); + +import { RuntimeAuthoringIssueSchema } from './protocol.zod'; + +/** + * #4717 — `advisories`, the #4463 D3 advisory half, on the save response. + * + * The gate runs the shared author-time rule registry over every body going + * `active` and splits its findings by severity. `error` becomes the 422; the + * rest are advisory — they do not block the write, and until #4717 they went + * only to a process-deduped `console.warn`, which is unreachable by exactly the + * Studio / MCP / AI authors #4463 exists for. + * + * ⚠️ This field is CONDITIONAL, and that has a consequence worth stating where + * the next reader will meet it: the producer-side conformance gate + * (`packages/objectql/src/save-meta-response-conformance.test.ts`) works by + * asserting that nothing was stripped, and a key that is absent strips nothing. + * So it stays green whether or not this declaration exists — measured, not + * assumed (the PR's R1/R3 rows). The declaration is deliberate rather than + * test-driven, and these cases are what make it checkable at all. + */ +describe('SaveMetaItemResponseSchema.advisories (#4717 — #4463 D3 on the response)', () => { + const realResponse = { + success: true, + version: 'sha256:7aad99c8d969efb5067fff275fb3e5be7ec90f9cd610d41709fcddbf8c34b1f0', + seq: 1, + state: 'active', + message: 'Saved flow \'nightly_purge\' (env-wide, state=active) [seq=1]', + }; + + /** 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, + }; + + it('carries a real advisory through parse without stripping it', () => { + const parsed = SaveMetaItemResponseSchema.parse({ ...realResponse, advisories: [advisory] }); + expect(parsed.advisories).toEqual([advisory]); + }); + + it('is OPTIONAL — absence means "nothing to report", never "the gate did not run"', () => { + expect(SaveMetaItemResponseSchema.safeParse(realResponse).success).toBe(true); + expect(SaveMetaItemResponseSchema.parse(realResponse).advisories).toBeUndefined(); + }); + + it('does not fabricate an empty array when the key is absent', () => { + // The producer omits the key rather than emitting `[]`, so a clean save's + // response bytes are unchanged. A `.default([])` here would quietly undo + // that on the consumer side: every caller would see a key the server never + // sent, and "absent" would stop being distinguishable at all. + expect('advisories' in SaveMetaItemResponseSchema.parse(realResponse)).toBe(false); + }); + + it('rejects a non-array, so a single issue object cannot masquerade as the list', () => { + expect( + SaveMetaItemResponseSchema.safeParse({ ...realResponse, advisories: advisory }).success, + ).toBe(false); + }); +}); + +/** + * The element shape itself — declared once (#4717) and re-exported by + * `@objectstack/metadata-protocol` as its `RuntimeAuthoringIssue`, so the 422's + * `issues[]` and the 2xx's `advisories[]` cannot drift into two dialects. + */ +describe('RuntimeAuthoringIssueSchema (#4717 — the ONE finding shape)', () => { + const issue = { + rule: 'flow-multi-write-unfiltered', + path: 'flows[0].nodes[1].config.multi', + where: 'flow "nightly_purge" · node "purge"', + message: 'unbounded bulk delete', + hint: 'add a filter', + severity: 'warning', + }; + + it('accepts a finding with all six keys', () => { + expect(RuntimeAuthoringIssueSchema.parse(issue)).toEqual(issue); + }); + + it('requires every one of the six — a partial finding is not a finding', () => { + for (const missing of ['rule', 'path', 'where', 'message', 'hint', 'severity'] as const) { + const body: Record = { ...issue }; + delete body[missing]; + expect( + RuntimeAuthoringIssueSchema.safeParse(body).success, + `omitting '${missing}' must fail parse`, + ).toBe(false); + } + }); + + it('closes severity to the three the gate emits', () => { + for (const severity of ['error', 'warning', 'info']) { + expect(RuntimeAuthoringIssueSchema.safeParse({ ...issue, severity }).success).toBe(true); + } + expect(RuntimeAuthoringIssueSchema.safeParse({ ...issue, severity: 'advisory' }).success).toBe(false); + expect(RuntimeAuthoringIssueSchema.safeParse({ ...issue, severity: 'fatal' }).success).toBe(false); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index c0ed6a0700..c6c439d27d 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -420,6 +420,46 @@ export const GetMetaItemLayeredResponseSchema = lazySchema(() => z.object({ resettable: z.boolean().describe('Whether the item can be reset to its packaged default. Always present on this path.'), })); +/** + * One finding from the #4463 runtime authoring gate — the fourth door. + * + * The gate runs the SHARED author-time rule registry (`@objectstack/lint`'s + * `AUTHORING_RULES`, the same table `os validate` / `os build` / `os lint` run) + * over a body about to go `active`, and partitions its findings by severity. + * The `error` half becomes the 422 `invalid_metadata` envelope's `issues[]`; + * the rest are ADVISORY — they do not block the write, and #4463 D3 decided + * they ride the 2xx response instead of being discarded. + * + * This is that ONE element shape, declared once and used by both halves, which + * is the whole point of D3's "reuse the Zod envelope": a consumer reads the + * same six keys whether the verdict arrived on a refusal or on a success. + * `@objectstack/metadata-protocol` re-exports this type as its + * `RuntimeAuthoringIssue` rather than declaring a second interface (#4717). + */ +export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({ + rule: z.string().describe( + 'Stable diagnostic rule id (`flow-multi-write-unfiltered`, ' + + '`approval-expression-invalid`, …). Machine-readable and stable across ' + + 'releases — the key a renderer groups or suppresses by.', + ), + path: z.string().describe( + 'Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), ' + + 'so an editor can jump to the offending key. May be empty when the ' + + 'finding is about the item as a whole.', + ), + where: z.string().describe( + 'Human-readable location — `flow "leave_approval" · node "approve"`. Prose ' + + 'for a person; use `path` for anything mechanical.', + ), + message: z.string().describe('What is wrong, in the rule author\'s own words.'), + hint: z.string().describe('How to fix it.'), + severity: z.enum(['error', 'warning', 'info']).describe( + 'How the gate treated this finding. `error` means the write was REFUSED ' + + '(these appear on the 422, never on a 2xx); `warning` / `info` are ' + + 'advisory — the write succeeded and the finding is FYI.', + ), +})); + /** * Save Metadata Item Request * Create or update a metadata item @@ -486,6 +526,24 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ + 'never thrown, so a caller that needs the read model to be live must check ' + '`projectionApplied.success` rather than rely on the 200.', ), + advisories: z.array(RuntimeAuthoringIssueSchema).optional().describe( + 'Non-gating findings from the #4463 runtime authoring gate — the same ' + + 'shared author-time rules `os validate` / `os build` / `os lint` run, ' + + 'applied to this body on its way to `active`. The write SUCCEEDED; these ' + + 'are what the gate has to say about it anyway (#4717, closing #4463 D3). ' + + 'Present ONLY when at least one advisory was raised — an empty array is ' + + 'never emitted, so a clean save\'s response bytes are unchanged and ' + + 'absence means "nothing to report", never "the gate did not run". ' + + 'Advisory by construction: every entry has `severity` `warning` or ' + + '`info`, because an `error` finding refuses the write and arrives as the ' + + '422 `invalid_metadata` envelope instead of here. A caller that ignores ' + + 'this key behaves exactly as before. Runtime-only: the CLI surfaces the ' + + 'same findings on its own stdout, and a Studio / MCP / AI author has no ' + + 'CLI at all, which is the gap #4463 exists to close. NOTE the door ' + + 'asymmetry — `POST /meta/:type/:name/publish` does not carry this field ' + + 'yet (its declaration landed separately as #7294); the gate runs on both ' + + 'doors, only the save door reports.', + ), message: z.string().optional(), })); @@ -1724,6 +1782,12 @@ export type GetMetaItemsResponse = z.input; export type GetMetaItemRequest = z.input; export type GetMetaItemResponse = z.input; export type GetMetaItemLayeredResponse = z.input; +/** + * One #4463 runtime authoring-gate finding. The SINGLE declaration of the shape; + * `@objectstack/metadata-protocol` re-exports this rather than declaring its own + * (#4717), so the 422 `issues[]` and the 2xx `advisories[]` cannot drift apart. + */ +export type RuntimeAuthoringIssue = z.input; export type SaveMetaItemRequest = z.input; export type SaveMetaItemResponse = z.input; export type PublishMetaItemResponse = 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 b12042a414..afe3ee8595 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -265,7 +265,7 @@ import type * as M167 from './ui/view.zod.js'; import type * as M170 from './ui/component.zod.js'; // --------------------------------------------------------------------------- -// 825 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 826 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 @@ -471,6 +471,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 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 > >>; export type Iso139 = Assert, z.infer< typeof M28.GetMetaItemCachedRequestSchema > >>; @@ -1624,7 +1625,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 825 isomorphic pins', () => { + it('still declares all 826 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 @@ -1798,9 +1799,22 @@ describe('ADR-0122 type-alias convention', () => { // `XParsed`. Its id is `Iso836`, the next free one, not a number near its // neighbours: the ids are claims about pins and not positions (the same // rule the #4914 decrease and the #6604 entry above both record). + // + // 825 -> 826 is #4717's `RuntimeAuthoringIssueSchema` — the ONE element + // shape the #4463 runtime authoring gate reports a finding in, on both + // halves of D3: the 422 `issues[]` and the new 2xx `advisories[]` on + // `SaveMetaItemResponseSchema` (`Iso136`). Isomorphism MEASURED, not + // assumed: five `z.string()`s and one `z.enum` — no `.default()`, + // `.transform()`, `.catch()`, `.optional()` or `.pipe()` anywhere — so the + // two shapes coincide and ADR-0122 gives it a pin rather than an + // `XParsed`. Adding the OPTIONAL `advisories` key to + // `SaveMetaItemResponseSchema` contributes 0 of its own: `.optional()` + // widens input and output identically, so `Iso136` still holds and the + // count moves by exactly the one new schema. Its id is `Iso837`, the next + // free one — the ids are claims about pins, not positions. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert