From c9df15f0376e5a7df2ae6d93c069548ddaf64553 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 00:31:40 +0000 Subject: [PATCH 1/4] feat(spec,objectql,metadata-protocol): validate-only data operation (#6037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4633 ruling D: import's dry run stops PREDICTING the write's verdict with a hand-copied mirror of the engine's rules and starts ASKING for it. `DataProtocol.validateData` reports the write path's verdict on candidate rows and persists nothing. Declaration and execution land together — a ruling clause, not a style note: `BatchOptions.validateOnly` was retired in #4052 as a dry-run flag that promised a preview while the batch surfaces persisted regardless. The new operation avoids that spelling and leaves the tombstone standing. `engine.validate()` calls the same validateRecord / evaluateValidationRules that insert() calls, so preview == write is guaranteed by construction; a test asserts it by running both against one engine under both ADR-0104 postures. The response carries the posture it was reached under — a bad value shape is an error on a self-certified deployment and an admitted warning on a warn-first one, which is why option B (unconditional strict) was rejected. Two documented boundaries: no hooks run (firing user hooks in a preview would be the #4052 defect respelled), and warn-first admissions are not recorded as #4769 certification evidence (a preview writes nothing, so it must not block a later migration). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G3U9PJm1hEJitS9LtZz8TC --- .changeset/validate-only-data-protocol.md | 69 ++++++ content/docs/references/api/protocol.mdx | 45 +++- content/docs/references/index.mdx | 10 +- ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/metadata-protocol/src/protocol.ts | 25 +++ .../src/protocol.validate-data.test.ts | 99 +++++++++ packages/objectql/src/engine.ts | 132 ++++++++++- packages/objectql/src/validate-only.test.ts | 209 ++++++++++++++++++ .../src/validation/record-validator.ts | 4 +- packages/spec/api-surface/api.json | 6 + packages/spec/authorable-surface/api.json | 11 + packages/spec/json-schema.manifest/api.json | 3 + packages/spec/src/api/protocol.zod.ts | 85 +++++++ packages/spec/src/api/validate-data.test.ts | 127 +++++++++++ 14 files changed, 816 insertions(+), 11 deletions(-) create mode 100644 .changeset/validate-only-data-protocol.md create mode 100644 packages/metadata-protocol/src/protocol.validate-data.test.ts create mode 100644 packages/objectql/src/validate-only.test.ts create mode 100644 packages/spec/src/api/validate-data.test.ts diff --git a/.changeset/validate-only-data-protocol.md b/.changeset/validate-only-data-protocol.md new file mode 100644 index 0000000000..859c7d4d7d --- /dev/null +++ b/.changeset/validate-only-data-protocol.md @@ -0,0 +1,69 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/metadata-protocol": minor +--- + +feat(spec,objectql,metadata-protocol): validate-only data operation — ask for the write's verdict instead of predicting it (#6037, #4633 ruling D) + +`import`'s dry run predicted the write path's verdict with a hand-copied mirror +of the engine's rules (`rest/src/import-coerce.ts`). A copy cannot structurally +keep up with the family it mirrors — ADR-0104 value shapes, `format` checks, +object-level `validations`, the state machine — so ruling D replaces prediction +with the verdict itself. + +**New:** `DataProtocol.validateData(request)` returns the write path's verdict +for candidate rows and persists nothing. + +```ts +const verdict = await protocol.validateData({ + object: 'lead', + mode: 'insert', // or 'update', which judges only supplied keys + data: [{ first_name: 'John', email: 'not-an-email' }], +}); +// → { valid: false, +// results: [{ valid: false, errors: [{ field: 'email', code: 'invalid_email', … }], warnings: [] }], +// posture: { valueShapeStrict: true, mediaValueShapeStrict: false } } +``` + +**Declaration and execution land together, deliberately.** `engine.validate()` +(objectql) calls the same `validateRecord` / `evaluateValidationRules` that +`insert()` calls, and `metadata-protocol` implements `validateData` on top of +it. Agreement between preview and write is therefore guaranteed by +construction, and a test asserts it directly by running both against one engine +in both postures. This is the ruling's own clause, not a style choice: +`BatchOptions.validateOnly` was retired in #4052 as a flag that promised a dry +run while the batch surfaces persisted regardless, so a caller previewing a +mutation had it EXECUTED. The new operation avoids that spelling too — the +tombstone still stands and still rejects `validateOnly`. + +**The verdict is the target deployment's, not an absolute.** The response +carries the ADR-0104 `posture` it was reached under. On a self-certified +deployment a bad value shape is an error; on a warn-first one the same row is +valid and the finding appears in `warnings` with the same `code` — one finding +that changed buckets, not two vocabularies. An unconditionally-strict preview +was considered and rejected (#4633 option B): it would fail rows on every +un-migrated deployment that the write would have accepted, which teaches +authors to distrust the one gate in front of a bulk import. + +Two boundaries worth knowing, both deliberate and both documented at the +implementation: + +- **No hooks run.** `beforeInsert` fires before validation on the real path, so + a hook deriving a *business* field could change a verdict this does not + simulate. Firing arbitrary user hooks in a preview — mail, outbound calls, + writes to other objects — is the #4052 defect in a new spelling, so the gap is + documented rather than closed. Audit/ownership stamps are `system`/`readonly` + and validation skips them regardless. +- **Warn-first admissions are not recorded as certification evidence.** The + `#4769` sink exists so a boot cannot certify a contract it has just written + against; a preview writes nothing, so recording there would let a *preview* + block a later migration. + +Additive: `validateData` is optional on `DataProtocol`, and nothing existing +changes shape. `valueShapeStrictEffective` / `mediaStrictEffective` are now +exported from objectql's record validator so the response reports the posture +that actually decided the verdict rather than the raw deployment flag. + +Unblocks #4633's consumption half (rest/import adopting the operation and +retiring the `import-coerce.ts` mirror). diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index e7b774e7b7..8cbc817c82 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, CreateViewRequestSchema, CreateViewResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DeleteViewRequestSchema, DeleteViewResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, GetViewRequestSchema, GetViewResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, ListViewsRequestSchema, ListViewsResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, UpdateViewRequestSchema, UpdateViewResponseSchema } 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, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse } 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, CreateViewRequestSchema, CreateViewResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DeleteViewRequestSchema, DeleteViewResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, GetViewRequestSchema, GetViewResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, ListViewsRequestSchema, ListViewsResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, UpdateViewRequestSchema, UpdateViewResponseSchema, 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, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -1614,3 +1614,44 @@ Uninstall package response --- +## ValidateDataIssue + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The field the finding is about (`_record` for an object-level rule). | +| **code** | `string` | ✅ | Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`. | +| **message** | `string` | ✅ | Human-readable message — a validation rule's author-written text where one exists. | + + +--- + +## ValidateDataRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The object name. | +| **data** | `Record \| Record[]` | ✅ | A candidate record, or an array of them. Nothing is persisted. | +| **mode** | `Enum<'insert' \| 'update'>` | optional | Which write the verdict should predict. `insert` (default) walks every declared field, so a missing required field is a finding; `update` judges only the supplied keys, matching a PATCH. | + + +--- + +## ValidateDataResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | The object name. | +| **mode** | `Enum<'insert' \| 'update'>` | ✅ | The write mode the verdict was reached for. | +| **valid** | `boolean` | ✅ | True when EVERY row is valid — the whole-set answer. | +| **results** | `{ valid: boolean; errors: { field: string; code: string; message: string }[]; warnings: { field: string; code: string; message: string }[] }[]` | ✅ | Per-row verdicts, in submission order. | +| **posture** | `{ valueShapeStrict: boolean; mediaValueShapeStrict: boolean }` | ✅ | The ADR-0104 posture the verdict was reached under — reported because it is the difference between "this row is fine" and "this row is fine HERE". The same row can be an error on a self-certified deployment and an admitted warning on an un-migrated one, and a caller explaining a verdict needs to know which it got. An unconditionally-strict preview was considered and rejected (#4633 option B): it would fail rows on every un-migrated deployment that the write would have accepted. | + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 19de4e24de..a2faf3eb17 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 — 1601 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1604 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 | 416 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 28 | 419 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 164 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 146 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1601** | 14 protocol modules | +| **Total** | **200** | **1604** | 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, 416 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **28 pages, 419 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`, `CreateViewRequest`, `CreateViewResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DeleteViewRequest`, `DeleteViewResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `GetViewRequest`, `GetViewResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `ListViewsRequest`, `ListViewsResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `UpdateViewRequest`, `UpdateViewResponse` | +| [`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`, `CreateViewRequest`, `CreateViewResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DeleteViewRequest`, `DeleteViewResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `GetViewRequest`, `GetViewResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `ListViewsRequest`, `ListViewsResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `UpdateViewRequest`, `UpdateViewResponse`, `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 825d56f8e2..34277fbc65 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -266,7 +266,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 396 | +| `api/` | 401 | | `cloud/` | 83 | | `identity/` | 33 | | `integration/` | 10 | diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 54cc0f3c8d..8031298444 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5529,6 +5529,31 @@ export class ObjectStackProtocolImplementation implements throw recordNotFoundError(request.object, request.id); } + /** + * Validate-only (#6037 — #4633 ruling D): report the write path's verdict + * on candidate rows without persisting any of them. + * + * Deliberately thin. The verdict comes from `engine.validate()`, which + * calls the same `validateRecord` / `evaluateValidationRules` that + * `insert()` calls — so "the preview agrees with the write" is guaranteed + * by construction rather than by a mirror kept in step by hand. That + * mirror is what this replaces: `rest/src/import-coerce.ts` re-implemented + * a slice of the engine's rules and structurally could not predict the + * rest of the family (ADR-0104 value shapes, `format`, object-level + * `validations`, the state machine). + * + * Same object-existence gate as every other data entry point (#3770), so + * an unknown object fails the same way here as it would on the real write + * — a preview that 404s differently from its write is a mirror again. + */ + async validateData(request: { object: string, data: any, mode?: 'insert' | 'update', context?: any }) { + this.assertObjectRegistered(request.object); + return this.engine.validate(request.object, request.data, { + ...(request.mode !== undefined ? { mode: request.mode } : {}), + ...(request.context !== undefined ? { context: request.context } : {}), + }); + } + async createData(request: { object: string, data: any, context?: any }) { this.assertObjectRegistered(request.object); // [#3770] // [#3043] Ingress-level static-`readonly` strip — a non-system caller diff --git a/packages/metadata-protocol/src/protocol.validate-data.test.ts b/packages/metadata-protocol/src/protocol.validate-data.test.ts new file mode 100644 index 0000000000..0bb72579b5 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.validate-data.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#6037 / #4633 ruling D] `validateData` — the DataProtocol's validate-only +// operation. +// +// The ruling attached one clause to this operation specifically: DECLARATION +// AND EXECUTION MUST LAND TOGETHER. `BatchOptions.validateOnly` was retired in +// #4052 as a dry-run flag that "promised a dry-run" while every batch surface +// persisted regardless, so a caller previewing a mutation had it EXECUTED. +// These cases are what stops the new operation becoming the same thing: they +// assert it is wired to the engine's verdict rather than declared beside one. + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const SCHEMA = { + name: 'lead', + fields: { + company: { name: 'company', type: 'text', required: true }, + email: { name: 'email', type: 'email' }, + }, +}; + +/** An engine whose `validate` records how it was called. */ +function makeEngine(verdict?: any) { + return { + registry: { getObject: (n: string) => (n === 'lead' ? SCHEMA : undefined) }, + getObject: (n: string) => (n === 'lead' ? SCHEMA : undefined), + validate: vi.fn(async (object: string, _data: any, options?: any) => verdict ?? { + object, + mode: options?.mode ?? 'insert', + valid: true, + results: [{ valid: true, errors: [], warnings: [] }], + posture: { valueShapeStrict: false, mediaValueShapeStrict: false }, + }), + // Every write the operation must never reach. + insert: vi.fn(async () => { throw new Error('validateData must not write'); }), + update: vi.fn(async () => { throw new Error('validateData must not write'); }), + delete: vi.fn(async () => { throw new Error('validateData must not write'); }), + }; +} + +describe('validateData (#6037)', () => { + it('returns the ENGINE’s verdict rather than a verdict of its own', async () => { + const verdict = { + object: 'lead', + mode: 'insert', + valid: false, + results: [{ + valid: false, + errors: [{ field: 'company', code: 'required', message: 'company is required' }], + warnings: [], + }], + posture: { valueShapeStrict: true, mediaValueShapeStrict: false }, + }; + const engine = makeEngine(verdict); + const p = new ObjectStackProtocolImplementation(engine as any); + const res: any = await p.validateData({ object: 'lead', data: { email: 'a@b.com' } }); + // Passed through, not re-derived: a protocol layer that re-judged would be + // the hand-copied mirror this operation exists to retire. + expect(res).toEqual(verdict); + expect(engine.validate).toHaveBeenCalledTimes(1); + }); + + it('never touches a write path', async () => { + const engine = makeEngine(); + const p = new ObjectStackProtocolImplementation(engine as any); + await p.validateData({ object: 'lead', data: [{ company: 'Acme' }, { company: 'Globex' }] }); + expect(engine.insert).not.toHaveBeenCalled(); + expect(engine.update).not.toHaveBeenCalled(); + expect(engine.delete).not.toHaveBeenCalled(); + }); + + it('forwards `mode` and `context` to the engine, and omits them when unset', async () => { + const engine = makeEngine(); + const p = new ObjectStackProtocolImplementation(engine as any); + + await p.validateData({ object: 'lead', data: {}, mode: 'update', context: { userId: 'u1' } }); + expect(engine.validate).toHaveBeenLastCalledWith('lead', {}, { mode: 'update', context: { userId: 'u1' } }); + + // Absent keys are not forwarded as `undefined` — the engine's own defaults + // decide, so a caller that omits `mode` gets `insert` from one place. + await p.validateData({ object: 'lead', data: {} }); + expect(engine.validate).toHaveBeenLastCalledWith('lead', {}, {}); + }); + + it('rejects an unknown object the same way every other data entry point does (#3770)', async () => { + const engine = makeEngine(); + const p = new ObjectStackProtocolImplementation(engine as any); + // A preview that 404s differently from its write would be a mirror again. + await expect(p.validateData({ object: 'nope', data: {} })).rejects.toThrow(); + expect(engine.validate).not.toHaveBeenCalled(); + }); + + it('is declared on the protocol surface it claims (the #4052 non-repeat)', async () => { + const p = new ObjectStackProtocolImplementation(makeEngine() as any); + expect(typeof (p as any).validateData).toBe('function'); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9458ffec55..2ebd884c09 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -17,6 +17,10 @@ import { type DroppedFieldsEvent } from '@objectstack/spec/data'; import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; +// The validate-only result IS the protocol's response shape (#6037): the +// engine is what `metadata-protocol.validateData` returns, so letting the two +// drift would put a translation layer between a verdict and its contract. +import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/api'; import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken } from '@objectstack/spec/data'; // [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1) // runs, so `FilterArray` has exactly one lowering in the product. @@ -108,7 +112,7 @@ import { ExpressionEngine } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec'; import { bindHooksToEngine } from './hook-binder.js'; -import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField } from './validation/record-validator.js'; +import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields } from './validation/rule-validator.js'; import { resolveMasterDetailRelation } from './master-detail.js'; @@ -5294,6 +5298,132 @@ export class ObjectQL implements IObjectQLEngine { // FLS write gate throws rather than stripping. So neither member reports on // those here — only on what this path actually strips. Any FURTHER strip added // here must wire both members at its own site too. + /** + * Validate-only (#6037, #4633 ruling D) — run the write path's own verdict + * over candidate rows and report it, WITHOUT persisting anything. + * + * ## Why this exists + * + * `import`'s dry run used to predict the write's verdict with a hand-copied + * mirror of the engine's rules (`rest/src/import-coerce.ts`). A copy cannot + * structurally keep up with the family it mirrors — value shapes and their + * ADR-0104 posture, `format` checks, object-level `validations`, the state + * machine — so the ruling replaced prediction with the verdict itself. The + * point is that agreement is guaranteed **by construction**: this method + * calls the same `validateRecord` and `evaluateValidationRules`, with the + * same options, that `insert()` calls a few hundred lines below. + * + * ## ADR-0104 posture — the whole reason B was rejected + * + * The verdict is resolved against the TARGET DEPLOYMENT'S REAL POSTURE via + * the same `valueShapeStrictFor` / `mediaValueShapeStrictFor` the write path + * uses. On a self-certified (strict) deployment a bad value shape is an + * error here exactly as it would be on write; on a warn-first deployment it + * is admitted here exactly as it would be on write, and reported as a + * WARNING rather than an error. An unconditionally-strict dry run (option B) + * was rejected precisely because it would fail rows on every un-migrated + * deployment that the write would have accepted — a false alarm that teaches + * authors to distrust the one gate in front of a bulk import. + * + * The warn-first admissions are deliberately NOT routed to + * `admittedViolationSink`: that sink records "this boot has written data + * against the old contract" so the deployment cannot then certify itself + * (#4769). A dry run writes nothing, so recording an admission would make a + * *preview* block a later migration — a side effect on a call whose whole + * contract is to have none. + * + * ## What it deliberately does NOT simulate + * + * No hooks run. `beforeInsert` fires BEFORE validation on the real path, so + * a hook that derives a business field could in principle change a verdict + * this method reports. Running arbitrary user-authored hooks to close that + * gap is the worse trade by a wide margin — a "validate without persisting" + * call that fires side-effecting hooks (mail, outbound calls, writes to + * other objects) is the #4052 defect in a new spelling, where a preview + * quietly executes. So the gap is documented rather than closed: audit and + * ownership stamps are `system`/`readonly` and are skipped by validation + * anyway, so what remains is the narrow case of a hook deriving a + * *business* field that its object also validates. + * + * Nothing is written, no sequence is consumed, and no driver is touched — + * validation is in-process, which is what makes row-by-row dry run of a + * large import affordable. + */ + async validate( + object: string, + data: Record | Record[], + options?: { mode?: 'insert' | 'update'; context?: ExecutionContext }, + ): Promise { + object = this.resolveObjectName(object); + const mode = options?.mode ?? 'insert'; + const rows = Array.isArray(data) ? data : [data]; + const schemaForValidation = this._registry.getObject(object); + + // Resolved once for the whole set, exactly as the write path resolves them + // once per batch — this is the "same posture as the real write" guarantee. + const mediaValueShapeStrict = await this.mediaValueShapeStrictFor(schemaForValidation); + const valueShapeStrict = await this.valueShapeStrictFor(schemaForValidation); + const messages = this.validationMessageContext(object, options?.context); + const currentUser = this.buildEvalUser(options?.context); + const skipStateMachine = shouldSkipStateMachine(options?.context); + + const results: NonNullable = rows.map((row) => { + const warnings: ValidateDataIssue[] = []; + // Warn-first admissions are the posture signal the caller came for, so + // they are reported — into this row's own bucket, never into the + // certification sink (see the note above). + // + // Carried under the code the STRICT path would have FAILED with + // (`invalid_type`) rather than a warning-specific one: the same bad + // value is one finding that changed buckets when the posture changed, + // and a caller diffing a preview against a write should see that, not + // two vocabularies. The sink's payload is `{gate, field, type, detail}`, + // so the message is composed the way the warn-first log line composes it. + const onAdmittedValueShapeViolation = (violation: any) => { + const field = String(violation?.field ?? ''); + const type = String(violation?.type ?? ''); + const detail = String(violation?.detail ?? 'invalid value shape'); + warnings.push({ + field, + code: 'invalid_type', + message: `${field} has an invalid ${type} value: ${detail}`, + }); + }; + try { + validateRecord(schemaForValidation, row, mode, { + mediaValueShapeStrict, valueShapeStrict, messages, onAdmittedValueShapeViolation, + }); + evaluateValidationRules(schemaForValidation as any, row, mode, { + logger: this.logger, currentUser, skipStateMachine, messages, + }); + } catch (e) { + if (e instanceof ValidationError) { + return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings }; + } + throw e; + } + return { valid: true, errors: [], warnings }; + }); + + return { + object, + mode, + valid: results.every((r) => r.valid), + results, + // The EFFECTIVE posture, not the raw deployment flag. `validateRecord` + // runs the flag through `valueShapeStrictEffective`, where the ADR-0104 + // env switches take precedence over it, so reporting the flag would + // describe a different posture than the one that just decided the + // verdict — on a deployment holding the flag but running with + // `OS_ALLOW_LAX_VALUE_SHAPES`, exactly backwards. Reporting what decided + // is the whole point of returning it. + posture: { + valueShapeStrict: valueShapeStrictEffective(valueShapeStrict), + mediaValueShapeStrict: mediaStrictEffective(mediaValueShapeStrict), + }, + }; + } + async insert(object: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise { object = this.resolveObjectName(object); this.logger.debug('Insert operation starting', { object, isBatch: Array.isArray(data) }); diff --git a/packages/objectql/src/validate-only.test.ts b/packages/objectql/src/validate-only.test.ts new file mode 100644 index 0000000000..306c1973ff --- /dev/null +++ b/packages/objectql/src/validate-only.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `engine.validate()` — validate-only (#6037, #4633 ruling D). + * + * The operation exists so a dry run can stop PREDICTING the write's verdict + * and start ASKING for it. Two properties therefore carry the whole design, + * and both are pinned here rather than described: + * + * 1. **Nothing is written.** A preview that persists is #4052's retired + * `validateOnly` all over again — a flag that promised a dry run while the + * batch surfaces persisted regardless. + * 2. **The verdict is the TARGET DEPLOYMENT'S.** ADR-0104 value shapes are + * rejected on a self-certified deployment and admitted on a warn-first + * one, and the ruling rejected option B (unconditional strict) precisely + * because it would fail rows that the local write would accept. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from './engine'; +import type { IDataDriver } from '@objectstack/spec/contracts'; + +/** Records every write the driver is asked to make, so "wrote nothing" is provable. */ +function makeDriver(writes: string[]): IDataDriver { + return { + name: 'default', + version: '1.0.0', + async connect() {}, + async disconnect() {}, + async find() { return []; }, + async findOne() { return null; }, + async count() { return 0; }, + async create(o: string, data: any) { writes.push(`create:${o}`); return { ...data, id: 'rec_1' }; }, + async update(o: string, id: string, data: any) { writes.push(`update:${o}`); return { ...data, id }; }, + async delete(o: string) { writes.push(`delete:${o}`); return true; }, + async bulkCreate(o: string, rows: any[]) { writes.push(`bulkCreate:${o}`); return rows; }, + async syncSchema() {}, + async dropTable() {}, + } as unknown as IDataDriver; +} + +const LEAD = { + name: 'lead', + fields: { + id: { type: 'text' }, + email: { type: 'email' }, + company: { type: 'text', required: true }, + score: { type: 'number', min: 0, max: 100 }, + // A covered value-shape type — this is the field the posture cases use. + account: { type: 'lookup', reference: 'account' }, + }, +}; + +function makeEngine(objects: any[] = [LEAD]) { + const writes: string[] = []; + const engine = new ObjectQL(); + engine.registerDriver(makeDriver(writes), true); + engine.registerApp({ id: 'vp', name: 'Validate Preview', objects } as any); + return { engine, writes }; +} + +afterEach(() => { + delete process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED; + delete process.env.OS_ALLOW_LAX_VALUE_SHAPES; +}); + +describe('engine.validate() — validate-only (#6037)', () => { + it('accepts a well-formed row and reports no findings', async () => { + const { engine } = makeEngine(); + const out = await engine.validate('lead', { company: 'Acme', email: 'a@b.com', score: 10 }); + expect(out.valid).toBe(true); + expect(out.results).toHaveLength(1); + expect(out.results![0].errors).toEqual([]); + expect(out.results![0].warnings).toEqual([]); + expect(out.object).toBe('lead'); + expect(out.mode).toBe('insert'); + }); + + it('reports the same field findings the write path would reject with', async () => { + const { engine } = makeEngine(); + const out = await engine.validate('lead', { email: 'not-an-email', score: 999 }); + expect(out.valid).toBe(false); + const codes = Object.fromEntries(out.results![0].errors.map((e) => [e.field, e.code])); + expect(codes.email).toBe('invalid_email'); + expect(codes.score).toBe('max_value'); + // `company` is required and absent — an insert walks every declared field. + expect(codes.company).toBe('required'); + }); + + it('writes NOTHING — not even for a row that would have been accepted', async () => { + const { engine, writes } = makeEngine(); + const out = await engine.validate('lead', [{ company: 'Acme' }, { company: 'Globex' }]); + expect(out.valid).toBe(true); + expect(writes).toEqual([]); + }); + + it('returns one verdict per row, in submission order', async () => { + const { engine } = makeEngine(); + const out = await engine.validate('lead', [ + { company: 'Acme' }, + { company: 'Globex', email: 'bad' }, + { company: 'Initech' }, + ]); + expect(out.results!.map((r) => r.valid)).toEqual([true, false, true]); + expect(out.valid).toBe(false); + expect(out.results![1].errors[0].field).toBe('email'); + }); + + it('judges only supplied keys in `update` mode, matching a PATCH', async () => { + const { engine } = makeEngine(); + // `company` is required but absent. An insert rejects that; a PATCH that + // does not mention the field must not. + expect((await engine.validate('lead', { email: 'a@b.com' }, { mode: 'insert' })).valid).toBe(false); + expect((await engine.validate('lead', { email: 'a@b.com' }, { mode: 'update' })).valid).toBe(true); + }); + + // ────────────────────────────────────────────────────────────────────────── + // ADR-0104 posture — the reason option B was rejected. Same row, same + // engine, two deployments, two legitimate verdicts. + // ────────────────────────────────────────────────────────────────────────── + describe('ADR-0104 posture is the deployment’s, not a constant', () => { + // A `lookup` whose value is not a well-formed reference. + const badShape = { company: 'Acme', account: { nope: true } }; + + it('REJECTS a bad value shape on a self-certified (strict) deployment', async () => { + process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED = '1'; + const { engine } = makeEngine(); + const out = await engine.validate('lead', badShape); + expect(out.posture.valueShapeStrict).toBe(true); + expect(out.valid).toBe(false); + expect(out.results![0].errors.some((e) => e.field === 'account')).toBe(true); + expect(out.results![0].warnings).toEqual([]); + }); + + it('ADMITS the same row on a warn-first deployment, and says so as a warning', async () => { + process.env.OS_ALLOW_LAX_VALUE_SHAPES = '1'; + const { engine } = makeEngine(); + const out = await engine.validate('lead', badShape); + expect(out.posture.valueShapeStrict).toBe(false); + // Valid — because the WRITE here would store it. Reporting `failed` + // would be the false alarm the ruling rejected (#4633 option B). + expect(out.valid).toBe(true); + expect(out.results![0].errors).toEqual([]); + const warning = out.results![0].warnings.find((w) => w.field === 'account'); + expect(warning).toBeDefined(); + // Same code as the strict rejection — one finding that changed buckets, + // not two vocabularies for one bad value. + expect(warning!.code).toBe('invalid_type'); + }); + + it('agrees with what the real write does, in BOTH postures', async () => { + // The property the whole ruling rests on: preview == write. Asserted by + // running both against the same engine rather than by reasoning about it. + for (const [envVar, expectWriteToSucceed] of [ + ['OS_ALLOW_LAX_VALUE_SHAPES', true], + ['OS_DATA_VALUE_SHAPE_STRICT_ENABLED', false], + ] as const) { + delete process.env.OS_ALLOW_LAX_VALUE_SHAPES; + delete process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED; + process.env[envVar] = '1'; + + const { engine } = makeEngine(); + const previewValid = (await engine.validate('lead', badShape)).valid; + + let writeSucceeded = true; + try { + await engine.insert('lead', { ...badShape }); + } catch { + writeSucceeded = false; + } + + expect(previewValid, `preview under ${envVar}`).toBe(expectWriteToSucceed); + expect(writeSucceeded, `write under ${envVar}`).toBe(expectWriteToSucceed); + expect(previewValid, `preview must match write under ${envVar}`).toBe(writeSucceeded); + } + }); + + it('does not record a warn-first admission as certification evidence (#4769)', async () => { + // The sink exists so a boot cannot certify a contract it has just + // written against. A preview writes nothing, so letting it record an + // admission would let a PREVIEW block a later migration. + process.env.OS_ALLOW_LAX_VALUE_SHAPES = '1'; + const { engine } = makeEngine(); + const sink: unknown[] = []; + (engine as any).admittedViolationSink = () => (v: unknown) => { sink.push(v); }; + await engine.validate('lead', badShape); + expect(sink).toEqual([]); + }); + }); + + it('surfaces object-level validation rules, not just field checks', async () => { + // One of the family the hand-copied mirror structurally could not predict. + const { engine } = makeEngine([{ + ...LEAD, + validations: [{ + name: 'score_needs_email', + type: 'cross_field', + // A `cross_field` predicate states the VIOLATION: `checkPredicate` + // reports a finding when the condition evaluates true. + condition: 'record.score > 50 && (record.email == null || record.email == "")', + message: 'A lead scored above 50 must carry an email.', + active: true, + }], + }]); + const out = await engine.validate('lead', { company: 'Acme', score: 80 }); + expect(out.valid).toBe(false); + expect(out.results![0].errors.some((e) => e.message.includes('must carry an email'))).toBe(true); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 98590fcdd5..006a64a775 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -714,7 +714,7 @@ function LAX_VALUE_SHAPES(): boolean { * of wrongly staying lenient is a warning nobody reads, while the cost of * wrongly enforcing is a working app that stops writing. */ -function mediaStrictEffective(deploymentVerified: boolean): boolean { +export function mediaStrictEffective(deploymentVerified: boolean): boolean { if (LAX_MEDIA_VALUES()) return false; if (VALUE_SHAPE_STRICT()) return true; return deploymentVerified; @@ -727,7 +727,7 @@ function mediaStrictEffective(deploymentVerified: boolean): boolean { * them would save three lines and lose the distinction the ADR spent an * addendum drawing. */ -function valueShapeStrictEffective(deploymentVerified: boolean): boolean { +export function valueShapeStrictEffective(deploymentVerified: boolean): boolean { if (LAX_VALUE_SHAPES()) return false; if (VALUE_SHAPE_STRICT()) return true; return deploymentVerified; diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index f1f253ac7b..761ac5507c 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -973,6 +973,12 @@ "UserProfileResponse (type)", "UserProfileResponseParsed (type)", "UserProfileResponseSchema (const)", + "ValidateDataIssue (type)", + "ValidateDataIssueSchema (const)", + "ValidateDataRequest (type)", + "ValidateDataRequestSchema (const)", + "ValidateDataResponse (type)", + "ValidateDataResponseSchema (const)", "ValidationMode (type)", "VersionDefinition (type)", "VersionDefinitionSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 5b9ac69a22..78e361db81 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1624,6 +1624,17 @@ "api/UserProfileResponse:error", "api/UserProfileResponse:meta", "api/UserProfileResponse:success", + "api/ValidateDataIssue:code", + "api/ValidateDataIssue:field", + "api/ValidateDataIssue:message", + "api/ValidateDataRequest:data", + "api/ValidateDataRequest:mode", + "api/ValidateDataRequest:object", + "api/ValidateDataResponse:mode", + "api/ValidateDataResponse:object", + "api/ValidateDataResponse:posture", + "api/ValidateDataResponse:results", + "api/ValidateDataResponse:valid", "api/VersionDefinition:breakingChanges", "api/VersionDefinition:deprecatedAt", "api/VersionDefinition:description", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index a1beda55ef..800ccac6e7 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -405,6 +405,9 @@ "api/UploadChunkResponse", "api/UploadProgress", "api/UserProfileResponse", + "api/ValidateDataIssue", + "api/ValidateDataRequest", + "api/ValidateDataResponse", "api/ValidationMode", "api/VersionDefinition", "api/VersionNegotiationResponse", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 4ed8cd462a..d29a0179ac 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -502,6 +502,71 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({ ), })); +/** + * One field-level finding from a validate-only run — the same + * `{ field, code, message }` triple the engine's `ValidationError.fields` + * carries, so a caller reads one vocabulary whether the verdict arrived from a + * preview or from a rejected write. + */ +export const ValidateDataIssueSchema = lazySchema(() => z.object({ + field: z.string().describe('The field the finding is about (`_record` for an object-level rule).'), + code: z.string().describe('Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`.'), + message: z.string().describe('Human-readable message — a validation rule\'s author-written text where one exists.'), +})); + +/** + * Validate Data Request (#6037 — #4633 ruling D) + * + * Ask for the write path's verdict on candidate rows WITHOUT writing them. + * + * @example + * { + * "object": "leads", + * "mode": "insert", + * "data": [{ "first_name": "John", "email": "not-an-email" }] + * } + */ +export const ValidateDataRequestSchema = lazySchema(() => z.object({ + object: z.string().describe('The object name.'), + data: z.union([ + z.record(z.string(), z.unknown()), + z.array(z.record(z.string(), z.unknown())), + ]).describe('A candidate record, or an array of them. Nothing is persisted.'), + mode: z.enum(['insert', 'update']).optional().describe( + "Which write the verdict should predict. `insert` (default) walks every declared field, so a " + + "missing required field is a finding; `update` judges only the supplied keys, matching a PATCH.", + ), +})); + +/** + * Validate Data Response + * + * One entry per submitted row, in submission order. + */ +export const ValidateDataResponseSchema = lazySchema(() => z.object({ + object: z.string().describe('The object name.'), + mode: z.enum(['insert', 'update']).describe('The write mode the verdict was reached for.'), + valid: z.boolean().describe('True when EVERY row is valid — the whole-set answer.'), + results: z.array(z.object({ + valid: z.boolean().describe('True when this row would be accepted by the write path.'), + errors: z.array(ValidateDataIssueSchema).describe('Findings that would REJECT this row. Empty when valid.'), + warnings: z.array(ValidateDataIssueSchema).describe( + 'Findings the target deployment ADMITS rather than rejects — today, ADR-0104 value shapes under a ' + + 'warn-first posture. The row is valid; the write would store it and log the same complaint.', + ), + })).describe('Per-row verdicts, in submission order.'), + posture: z.object({ + valueShapeStrict: z.boolean().describe('True when this deployment rejects non-conforming value shapes (ADR-0104 self-certified).'), + mediaValueShapeStrict: z.boolean().describe('The same, for media field value shapes.'), + }).describe( + 'The ADR-0104 posture the verdict was reached under — reported because it is the difference between ' + + '"this row is fine" and "this row is fine HERE". The same row can be an error on a self-certified ' + + 'deployment and an admitted warning on an un-migrated one, and a caller explaining a verdict needs to ' + + 'know which it got. An unconditionally-strict preview was considered and rejected (#4633 option B): it ' + + 'would fail rows on every un-migrated deployment that the write would have accepted.', + ), +})); + /** * Update Data Request * Modification of an existing record. @@ -1329,6 +1394,9 @@ export type GetDataRequest = z.input; export type GetDataResponse = z.input; export type CreateDataRequest = z.input; export type CreateDataResponse = z.input; +export type ValidateDataIssue = z.input; +export type ValidateDataRequest = z.input; +export type ValidateDataResponse = z.input; export type UpdateDataRequest = z.input; export type UpdateDataResponse = z.input; export type DeleteDataRequest = z.input; @@ -1520,6 +1588,23 @@ export interface DataProtocol { updateData(request: UpdateDataRequest): Promise; deleteData(request: DeleteDataRequest): Promise; + /** + * Validate-only (#6037 — #4633 ruling D): the write path's verdict on + * candidate rows, with nothing persisted. + * + * Declared optional because it is additive to a shipped contract, not + * because it is aspirational — `metadata-protocol` implements it in the same + * change that declares it. That is a ruling clause, not a style note: + * `BatchOptions.validateOnly` was retired in #4052 as a dry-run flag that + * "promised a dry-run" while every batch surface persisted regardless, so a + * caller previewing a mutation had it EXECUTED. A second declared-and-unmet + * validation promise is the one outcome this operation must not become. + * + * The verdict honours the deployment's real ADR-0104 posture, so it predicts + * what THIS deployment would do — see `ValidateDataResponseSchema.posture`. + */ + validateData?(request: ValidateDataRequest): Promise; + // Batch Operations (optional) batchData?(request: BatchDataRequest): Promise; createManyData?(request: CreateManyDataRequest): Promise; diff --git a/packages/spec/src/api/validate-data.test.ts b/packages/spec/src/api/validate-data.test.ts new file mode 100644 index 0000000000..122e54cb9d --- /dev/null +++ b/packages/spec/src/api/validate-data.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#6037 / #4633 ruling D] The validate-only DataProtocol contract. +// +// The ruling carried two clauses aimed squarely at this contract, and both are +// pinned here because both are the kind that a later edit could quietly undo: +// +// 1. the operation name must avoid the retired-key vocabulary — `validateOnly` +// is tombstoned on `BatchOptions` (#4052) and reintroducing that spelling +// would collide with a tombstone whose whole job is to make it audible; +// 2. the response must carry the ADR-0104 posture the verdict was reached +// under, because option B (unconditionally strict) was rejected: a preview +// that ignores posture fails rows the local write would have accepted. + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { + ValidateDataRequestSchema, + ValidateDataResponseSchema, + ValidateDataIssueSchema, +} from './protocol.zod'; +import { BatchOptionsSchema } from './batch.zod'; + +describe('ValidateDataRequest (#6037)', () => { + it('accepts a single candidate row', () => { + const r = ValidateDataRequestSchema.safeParse({ object: 'lead', data: { company: 'Acme' } }); + expect(r.success).toBe(true); + }); + + it('accepts an array of candidate rows — the import dry-run shape', () => { + const r = ValidateDataRequestSchema.safeParse({ + object: 'lead', + data: [{ company: 'Acme' }, { company: 'Globex' }], + }); + expect(r.success).toBe(true); + }); + + it('accepts both write modes and defaults to none (the engine owns the default)', () => { + for (const mode of ['insert', 'update'] as const) { + expect(ValidateDataRequestSchema.safeParse({ object: 'lead', data: {}, mode }).success).toBe(true); + } + const bare = ValidateDataRequestSchema.safeParse({ object: 'lead', data: {} }); + expect(bare.success).toBe(true); + if (bare.success) expect(bare.data.mode).toBeUndefined(); + }); + + it('rejects a mode outside the write vocabulary', () => { + expect(ValidateDataRequestSchema.safeParse({ object: 'lead', data: {}, mode: 'upsert' }).success).toBe(false); + }); + + it('requires an object name', () => { + expect(ValidateDataRequestSchema.safeParse({ data: {} }).success).toBe(false); + }); +}); + +describe('ValidateDataResponse (#6037)', () => { + const ok = { + object: 'lead', + mode: 'insert' as const, + valid: false, + results: [{ + valid: false, + errors: [{ field: 'company', code: 'required', message: 'company is required' }], + warnings: [], + }], + posture: { valueShapeStrict: true, mediaValueShapeStrict: false }, + }; + + it('accepts a per-row verdict carrying errors and warnings', () => { + expect(ValidateDataResponseSchema.safeParse(ok).success).toBe(true); + }); + + it('requires the ADR-0104 posture — the half that makes a verdict explainable', () => { + // Option B (unconditional strict) was rejected, so a verdict is only + // meaningful alongside the posture it was reached under. Optional posture + // would let an implementation quietly stop reporting it. + const { posture, ...withoutPosture } = ok; + expect(ValidateDataResponseSchema.safeParse(withoutPosture).success).toBe(false); + }); + + it('separates admitted findings from rejecting ones', () => { + // A warn-first deployment ADMITS a bad value shape: the row is valid and + // the finding is a warning. Collapsing the two buckets would erase exactly + // the distinction the ruling turned on. + const warned = { + ...ok, + valid: true, + results: [{ + valid: true, + errors: [], + warnings: [{ field: 'account', code: 'invalid_type', message: 'account has an invalid lookup value: …' }], + }], + posture: { valueShapeStrict: false, mediaValueShapeStrict: false }, + }; + const parsed = ValidateDataResponseSchema.safeParse(warned); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.results[0].valid).toBe(true); + expect(parsed.data.results[0].warnings).toHaveLength(1); + } + }); + + it('uses one finding shape for both buckets', () => { + const issue = { field: 'company', code: 'required', message: 'company is required' }; + expect(ValidateDataIssueSchema.safeParse(issue).success).toBe(true); + // A caller diffing a preview against a rejected write reads one vocabulary. + expect(ValidateDataIssueSchema.safeParse({ field: 'x', code: 'y' }).success).toBe(false); + }); +}); + +describe('the #4052 non-repeat', () => { + it('does not reuse the retired `validateOnly` spelling', () => { + // `BatchOptions.validateOnly` is tombstoned: it promised a dry run that + // never existed. The new operation had to avoid that vocabulary so the + // tombstone keeps meaning what it says. + const keys = Object.keys((ValidateDataRequestSchema as unknown as z.ZodObject).shape); + expect(keys).not.toContain('validateOnly'); + expect(keys).toEqual(expect.arrayContaining(['object', 'data', 'mode'])); + }); + + it('leaves the BatchOptions tombstone standing — this operation is not its revival', () => { + const r = BatchOptionsSchema.safeParse({ validateOnly: true }); + expect(r.success).toBe(false); + // And the rejection still points somewhere useful. + expect(JSON.stringify(r.error?.issues)).toMatch(/validateOnly/); + }); +}); From cb16b38c0f83d621c184cf6b902651f3760a20a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 00:51:22 +0000 Subject: [PATCH 2/4] spec: regenerate strictness-ledger counts after merging main (#6037) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G3U9PJm1hEJitS9LtZz8TC --- docs/audits/2026-07-unknown-key-strictness-ledger.counts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 34277fbc65..68619d5e59 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -273,4 +273,4 @@ directory rather than per file. | `kernel/` | 319 | | `qa/` | 6 | | `shared/` | 20 | -| `system/` | 366 | +| `system/` | 367 | From 8f2b9d6eb2ae3d7cb6037a95a92408cbcf5ece84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 01:17:12 +0000 Subject: [PATCH 3/4] test(spec): pin ValidateData* aliases as isomorphic (ADR-0122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three new protocol aliases carry no defaults/transforms, so z.input and z.infer coincide — per ADR-0122 the complement is pinned in the registry rather than given a permanent-synonym XParsed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- packages/spec/src/type-alias-convention.pin.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 0845c25155..022383968e 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1372,6 +1372,9 @@ export type Iso749 = Assert, z.infer< typeof M28.GetLocalesRequestSchema > >>; export type Iso751 = Assert, z.infer< typeof M28.GetTranslationsRequestSchema > >>; export type Iso752 = Assert, z.infer< typeof M28.GetFieldLabelsRequestSchema > >>; +export type Iso755 = Assert, z.infer< typeof M28.ValidateDataIssueSchema > >>; +export type Iso756 = Assert, z.infer< typeof M28.ValidateDataRequestSchema > >>; +export type Iso757 = Assert, z.infer< typeof M28.ValidateDataResponseSchema > >>; // automation/builtin-node-config.zod.ts export type Iso753 = Assert, z.infer< typeof M172.ScreenFieldConfigSchema > >>; From 282ae88256b654a725b5e7fcdbe346096094ddcd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 01:28:56 +0000 Subject: [PATCH 4/4] test(spec): pin-count receipt 751 -> 754 for the ValidateData* pins The registry's own count case documents every movement; record the three-pin rise with its cause per the file's idiom. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- packages/spec/src/type-alias-convention.pin.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 022383968e..8082b72c28 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1486,9 +1486,13 @@ describe('ADR-0122 type-alias convention', () => { // was named. Inverting the gate asked, and 35 of the 57 it turned up // answered "isomorphic". A jump this size is normally the shape of a // mistake; this one is a gate widening, and the pins are its receipt. + // + // 751 -> 754 is #6037's `ValidateDataIssue` / `ValidateDataRequest` / + // `ValidateDataResponse` — three new protocol shapes with no defaults or + // transforms anywhere in their trees, i.e. the second (RISE) case above. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert {