diff --git a/.changeset/28005-thank-you-record-slot.md b/.changeset/28005-thank-you-record-slot.md new file mode 100644 index 00000000000..56b66b65923 --- /dev/null +++ b/.changeset/28005-thank-you-record-slot.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +feat(spec): a form's thank-you panel can name the record's engine-generated fields, and the public submit answer has a declared shape + +Clause-②: yes (widening) +Metadata-protocol: added + +A public form's confirmation panel could not tell a web-to-case visitor their case number: `title` and `message` were plain text, and `POST /api/v1/forms/SLUG/submit` answers `{ id }` only. + +- **The slot.** A form's `submitBehavior: { kind: 'thank-you', title, message }` may name a field of the record just submitted as `{{record.field_name}}`, the same token the `redirect` behavior's `url` takes. Any other brace is refused at parse, naming the offending text: `{record.x}`, `{{ record.x }}` and `{{case_number}}` are not the token. +- **Only engine-generated fields.** A named field must be one whose value the engine generates on insert: its type is in `RUNTIME_OWNED_FIELD_TYPES` (today `autonumber`). Any other stored value may have been set by a `beforeInsert` / `afterInsert` hook from records an anonymous visitor cannot read. `os validate`, `os build`, `os lint` and the publish gate on a `view` or `page` write refuse any other name (rule `thank-you-record-field`), naming the field and its type. This covers every form view shape and a page `object-form` component. +- **The answer shape.** `PublicFormSubmitResponseSchema` (`@objectstack/spec/api`) declares the submit answer as `id` plus an optional `fields` map holding exactly the named fields, and nothing else. `thankYouRecordFieldNames` (`@objectstack/spec/ui`) reads which fields a panel names. + +The server fills `fields` and the console fills the tokens in follow-up releases. Until then, a panel that names a field shows the token text as written. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index bdda74dc7bf..091ef3b3ecb 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -771,7 +771,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (56)... + → Running author-time rules (57)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 6319d0bdcbc..6d4a7b7eb93 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -759,7 +759,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (56)... + → Running author-time rules (57)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 22dcf5948ee..ec422157fc1 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -305,7 +305,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (56)... + → Running author-time rules (57)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 099a2de7ecc..774195c8308 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,7 +1,7 @@ --- title: Protocol schema — API Protocol reference navTitle: Protocol -description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 140 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 141 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilit ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetRecordFieldAccessResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringClientSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsRequestSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, 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, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetRecordFieldAccessResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringClient, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsRequest, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, 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, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetRecordFieldAccessResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringClientSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublicFormSubmitResponseSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsRequestSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, 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, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetRecordFieldAccessResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringClient, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublicFormSubmitResponse, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsRequest, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, 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); @@ -2488,6 +2488,18 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | **push** | `boolean` | optional | Override push setting | +--- + +## PublicFormSubmitResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | The ID of the newly created record. | +| **fields** | `Record` | optional | The stored values of exactly the fields the form's `thank-you` panel names as `{{record.field_name}}`, keyed by field name — each one a field the engine generates on insert (an `autonumber` field, e.g. a case number). Present only when the panel names at least one; never any other field of the record. | + + --- ## PublishMetaItemRequest diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 4a560a68e8b..953017f7bcb 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1575 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1576 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 34 | 453 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 34 | 454 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,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 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **204** | **1575** | 14 protocol modules | +| **Total** | **204** | **1576** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 453 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 454 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -92,7 +92,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringClient`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsRequest`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `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`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringClient`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublicFormSubmitResponse`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsRequest`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `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/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index ef640b1bc67..5be9453d723 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -653,6 +653,14 @@ Sort field and direction pair | **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the object's `fieldGroups`. Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `visibleWhen`, and a `true` `collapsible` / `collapsed`) | | **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … } \| { name: string; label?: string; description?: string; type?: string; … })[]` | optional | The section's fields, in order — each a field name, the form view's `{ field, … }` entry overriding that object field, or an inline form field `{ name, type, … }`. Omit only when `group` supplies the members | +### Nested Shape: `ObjectFormProps.submitBehavior[kind='thank-you']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `'thank-you'` | ✅ | | +| **title** | `string` | optional | Heading of the confirmation panel. May name a field of the submitted record as `{{record.field_name}}`, and only a field the engine generates on insert (an `autonumber` field, e.g. a case number): a hook-derived value never reaches an anonymous visitor. | +| **message** | `string` | optional | Body of the confirmation panel. May name a field of the submitted record as `{{record.field_name}}`, and only a field the engine generates on insert (an `autonumber` field, e.g. a case number): a hook-derived value never reaches an anonymous visitor. | + ### Nested Shape: `ObjectFormProps.submitBehavior[kind='redirect']` | Property | Type | Required | Description | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index e1af9fe765c..c49b87e0793 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -491,6 +491,14 @@ Form-view select option — the object-field option shape minus the per-option ` | **expiresAt** | `string` | optional | Expiration date/time in ISO 8601 format | | **allowAnonymous** | `boolean` | optional (default: `false`) | Allow access without authentication | +### Nested Shape: `FormView.submitBehavior[kind='thank-you']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `'thank-you'` | ✅ | | +| **title** | `string` | optional | Heading of the confirmation panel. May name a field of the submitted record as `{{record.field_name}}`, and only a field the engine generates on insert (an `autonumber` field, e.g. a case number): a hook-derived value never reaches an anonymous visitor. | +| **message** | `string` | optional | Body of the confirmation panel. May name a field of the submitted record as `{{record.field_name}}`, and only a field the engine generates on insert (an `autonumber` field, e.g. a case number): a hook-derived value never reaches an anonymous visitor. | + ### Nested Shape: `FormView.submitBehavior[kind='redirect']` | Property | Type | Required | Description | diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 146b302cfa1..f171cf7c228 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -404,7 +404,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (56)... + → Running author-time rules (57)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 305b166416f..4c51f3708ac 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 463 | +| `api/` | 464 | diff --git a/packages/lint/src/authoring-rule-wiring.test.ts b/packages/lint/src/authoring-rule-wiring.test.ts index e315ea8acaf..8fd37410922 100644 --- a/packages/lint/src/authoring-rule-wiring.test.ts +++ b/packages/lint/src/authoring-rule-wiring.test.ts @@ -513,6 +513,8 @@ describe('authoring-rule registry wiring (#4409)', () => { 'validateReferenceIntegrity', // #25650 — a form open to anonymous intake declaring a file field. 'validatePublicFormFileFields', + // #28005 — a thank-you panel naming a field the engine does not generate. + 'validateThankYouRecordFields', ...FAMILY, ]); }); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index eeeff688735..5a7b522f5e6 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -129,6 +129,7 @@ import { validateFieldConsumers } from './validate-field-consumers.js'; import { validateSemanticRoles } from './validate-semantic-roles.js'; import { validateFormLayout } from './validate-form-layout.js'; import { validatePublicFormFileFields } from './validate-public-form-file-fields.js'; +import { validateThankYouRecordFields } from './validate-thank-you-record-fields.js'; import { validateSeedReplaySafety } from './validate-seed-replay-safety.js'; import { validateAssignmentRules } from './validate-assignment-rules.js'; import { validateSeedStateMachine } from './validate-seed-state-machine.js'; @@ -1612,6 +1613,25 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ runtimeTypes: ['view'], run: (stack) => validatePublicFormFileFields(stack), }, + // Ruling B on #28005 — a form's `thank-you` panel may name, as + // `{{record.field_name}}`, only a field the engine generates on insert + // (`RUNTIME_OWNED_FIELD_TYPES`): any other stored value may come from a hook + // the anonymous form grant cannot read, and the public submit answer never + // carries it. Gating, so all three commands run it. + // + // Runtime publish gate: a `view` or `page` write's snapshot carries + // `objects` as context, which is every input this rule reads. `object` is + // not declared, for the reason the file-field rule above gives. + { + name: 'validateThankYouRecordFields', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-thank-you-record-fields.ts', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['view', 'page'], + run: (stack) => validateThankYouRecordFields(stack), + }, // ADR-0078 Phase 3 (Tier-A `action-locations`) — an action that declares no // `locations` and that no view places by name renders on no surface at all. // objectui#3142 made that measurable: four renderers used to show an diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index cc04ec408cd..254bde79297 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -268,6 +268,8 @@ export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layo // A form open to anonymous intake that declares a file field (ruling C on #25572). export { validatePublicFormFileFields, PUBLIC_FORM_FILE_FIELD } from './validate-public-form-file-fields.js'; export type { PublicFormFileFieldFinding } from './validate-public-form-file-fields.js'; +export { validateThankYouRecordFields, THANK_YOU_RECORD_FIELD } from './validate-thank-you-record-fields.js'; +export type { ThankYouRecordFieldFinding } from './validate-thank-you-record-fields.js'; export { validateVisibilityPredicates, diff --git a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts index 64741ff644e..b123ea683bd 100644 --- a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts +++ b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts @@ -134,6 +134,9 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi 'validateComponentProps', 'validateComponentTypes', 'validatePrintPageBlocks', + // #28005 — an object-form's thank-you panel naming a field the engine + // does not generate. + 'validateThankYouRecordFields', ...FAMILY, // #24953 / #25002 — the props-bag CEL slots' bounds refusal, after the family. 'validatePagePredicateBounds', @@ -194,6 +197,7 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi 'validatePresetComparands', 'validateReferenceIntegrity', 'validatePublicFormFileFields', + 'validateThankYouRecordFields', ...FAMILY, ]); const view = (visibleWhen: string, data?: unknown) => ({ diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index c289524b536..a2f630c41fb 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -342,6 +342,9 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# // #25650 — a form open to anonymous intake declaring a file field; // the snapshot's `objects` context carries the field types it reads. 'validatePublicFormFileFields', + // #28005 — a thank-you panel naming a field the engine does not + // generate; the same `objects` context carries the types it reads. + 'validateThankYouRecordFields', 'validateVisibilityPredicates', 'validatePredicatePathRefs', ]); @@ -543,6 +546,7 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# 'validatePresetComparands', // #8793 — dispatched for `view`, clean here 'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here 'validatePublicFormFileFields', // #25650 — a public form's file fields, clean here + 'validateThankYouRecordFields', // #28005 — a thank-you panel's named fields, clean here 'validateVisibilityPredicates', 'validatePredicatePathRefs', ]); @@ -639,6 +643,7 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)', 'validatePresetComparands', // #8793 — dispatched for `view`, clean here 'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here 'validatePublicFormFileFields', // #25650 — a public form's file fields, clean here + 'validateThankYouRecordFields', // #28005 — a thank-you panel's named fields, clean here 'validateVisibilityPredicates', 'validatePredicatePathRefs', ]); diff --git a/packages/lint/src/validate-thank-you-record-fields.test.ts b/packages/lint/src/validate-thank-you-record-fields.test.ts new file mode 100644 index 00000000000..479d50660e6 --- /dev/null +++ b/packages/lint/src/validate-thank-you-record-fields.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Ruling B on #28005: a form's thank-you panel may name, as +// `{{record.field_name}}`, only a field the engine generates on insert +// (`RUNTIME_OWNED_FIELD_TYPES`). Pins the stack rule the three CLI commands run, +// the runtime publish gate on `view` and `page` writes, and that the admitted +// set is the spec's one set and no list of this rule's own. + +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { RUNTIME_OWNED_FIELD_TYPES } from '@objectstack/spec/data'; + +import { AUTHORING_RULES } from './authoring-rules.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { THANK_YOU_RECORD_FIELD, validateThankYouRecordFields } from './validate-thank-you-record-fields.js'; + +const support = { + name: 'support_case', + label: 'Support case', + fields: { + case_number: { type: 'autonumber', label: 'Case number', autonumberFormat: 'CASE-{0000}' }, + subject: { type: 'text', label: 'Subject' }, + priority_score: { type: 'formula', label: 'Priority score', expression: '1' }, + }, +}; + +const panel = (message: string) => ({ kind: 'thank-you', title: 'Thank you', message }); + +const formOf = (message: string) => ({ + type: 'simple', + data: { provider: 'object', object: 'support_case' }, + sections: [{ name: 'about', fields: ['subject'] }], + submitBehavior: panel(message), +}); + +const stackOf = (view: Record, objects: unknown = [support]) => ({ objects, views: [view] }); + +describe('validateThankYouRecordFields — the stack rule the CLI commands run', () => { + it('admits an autonumber field', () => { + expect(validateThankYouRecordFields(stackOf({ + name: 'case_views', + form: formOf('Your case number is {{record.case_number}}.'), + }))).toEqual([]); + }); + + it('refuses a text field, naming the field, its type and the reason', () => { + const findings = validateThankYouRecordFields(stackOf({ + name: 'case_views', + formViews: { intake: formOf('We got {{record.subject}} as {{record.case_number}}') }, + })); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: THANK_YOU_RECORD_FIELD, + where: 'view "case_views" · formViews.intake', + path: 'views[0].formViews.intake.submitBehavior', + }); + expect(findings[0].message).toContain('{{record.subject}}'); + expect(findings[0].message).toContain('a text field'); + expect(findings[0].message).toContain('hook'); + }); + + it('refuses a formula field the same way', () => { + const findings = validateThankYouRecordFields(stackOf({ name: 'v', form: formOf('{{record.priority_score}}') })); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('{{record.priority_score}}'); + expect(findings[0].message).toContain('a formula field'); + }); + + it('refuses a name the object does not declare, and an injected system column', () => { + const findings = validateThankYouRecordFields(stackOf({ + name: 'v', + form: formOf('{{record.no_such_field}} {{record.created_at}} {{record.id}}'), + })); + expect(findings.map((f) => f.message.match(/is (.*?)\. A thank-you/)?.[1])).toEqual([ + 'not a field the object declares', + expect.stringContaining('a system column'), + expect.stringContaining('a system column'), + ]); + }); + + it('reads the title as well as the message, each name once', () => { + const form = formOf('{{record.subject}}'); + (form.submitBehavior as Record).title = 'Re: {{record.subject}}'; + expect(validateThankYouRecordFields(stackOf({ name: 'v', form }))).toHaveLength(1); + }); + + it('judges every form shape a view carries, public or not', () => { + const bad = formOf('{{record.subject}}'); + expect(validateThankYouRecordFields(stackOf({ name: 'v', viewKind: 'form', config: bad })).map((f) => f.path)) + .toEqual(['views[0].config.submitBehavior']); + expect(validateThankYouRecordFields(stackOf({ name: 'v', ...bad })).map((f) => f.path)) + .toEqual(['views[0].submitBehavior']); + const open = { ...bad, sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/case' } }; + expect(validateThankYouRecordFields(stackOf({ name: 'v', form: open }))).toHaveLength(1); + }); + + it('resolves the object from the view when the form names none', () => { + const form: Record = formOf('{{record.subject}}'); + delete form.data; + expect(validateThankYouRecordFields(stackOf({ name: 'v', object: 'support_case', form }))).toHaveLength(1); + }); + + it('reads an object whose fields are a list', () => { + const object = { name: 'support_case', fields: [{ name: 'subject', type: 'text' }] }; + expect(validateThankYouRecordFields(stackOf({ name: 'v', form: formOf('{{record.subject}}') }, [object]))) + .toHaveLength(1); + }); + + it('does not judge a panel with no token, another kind, or an object outside the stack', () => { + expect(validateThankYouRecordFields(stackOf({ name: 'v', form: formOf('Thanks!') }))).toEqual([]); + const redirect = { ...formOf(''), submitBehavior: { kind: 'redirect', url: '/t/{{record.subject}}' } }; + expect(validateThankYouRecordFields(stackOf({ name: 'v', form: redirect }))).toEqual([]); + expect(validateThankYouRecordFields(stackOf({ name: 'v', form: formOf('{{record.subject}}') }, []))).toEqual([]); + }); + + it('judges a page object-form panel against its objectName', () => { + const page = { + name: 'case_page', + type: 'app', + regions: [{ + name: 'main', + components: [{ + id: 'intake', + type: 'object-form', + properties: { objectName: 'support_case', mode: 'create', submitBehavior: panel('{{record.subject}}') }, + }], + }], + }; + const findings = validateThankYouRecordFields({ objects: [support], pages: [page] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + where: 'page "case_page" · object-form "intake"', + path: 'pages[0].regions[0].components[0].properties.submitBehavior', + }); + const ok = structuredClone(page); + ok.regions[0].components[0].properties.submitBehavior = panel('{{record.case_number}}'); + expect(validateThankYouRecordFields({ objects: [support], pages: [ok] })).toEqual([]); + }); + + it('is registered gating, on all three commands and on the publish gate for view and page writes', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validateThankYouRecordFields'); + expect(entry).toMatchObject({ + tier: 'gating', + commands: ['validate', 'build', 'lint'], + surfaces: ['cli', 'runtime-publish'], + runtimeTypes: ['view', 'page'], + }); + }); +}); + +describe('the admitted set is RUNTIME_OWNED_FIELD_TYPES, the one source', () => { + it('in production it is the spec set itself', () => { + expect([...RUNTIME_OWNED_FIELD_TYPES]).toContain('autonumber'); + const stack = stackOf({ name: 'v', form: formOf('{{record.case_number}}') }); + expect(validateThankYouRecordFields(stack)).toEqual(validateThankYouRecordFields(stack, RUNTIME_OWNED_FIELD_TYPES)); + }); + + it('widening the set (a test double) admits the new type with no other edit', () => { + const stack = stackOf({ name: 'v', form: formOf('{{record.priority_score}}') }); + expect(validateThankYouRecordFields(stack)).toHaveLength(1); + const widened = new Set([...RUNTIME_OWNED_FIELD_TYPES, 'formula']); + expect(validateThankYouRecordFields(stack, widened)).toEqual([]); + }); + + it('the rule source spells no field type of its own', () => { + const source = readFileSync(new URL('./validate-thank-you-record-fields.ts', import.meta.url), 'utf8'); + expect(source).toContain("import { RUNTIME_OWNED_FIELD_TYPES } from '@objectstack/spec/data';"); + expect(source).toMatch(/thankYouRecordFieldNames \} from '@objectstack\/spec\/ui'/); + expect(source).not.toMatch(/'autonumber'|"autonumber"/); + }); +}); + +describe('the runtime publish gate', () => { + it('refuses a view write naming a text field, resolving the type from the objects context', () => { + const { errors } = runRuntimeAuthoringRules({ + type: 'view', + item: { name: 'case_views', form: formOf('{{record.subject}}') }, + context: { objects: [support] }, + }); + const finding = errors.find((e) => e.rule === THANK_YOU_RECORD_FIELD); + expect(finding, JSON.stringify(errors)).toBeDefined(); + expect(finding!.path).toContain('form.submitBehavior'); + }); + + it('admits the same view naming the autonumber field', () => { + const { errors } = runRuntimeAuthoringRules({ + type: 'view', + item: { name: 'case_views', form: formOf('{{record.case_number}}') }, + context: { objects: [support] }, + }); + expect(errors.filter((e) => e.rule === THANK_YOU_RECORD_FIELD)).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-thank-you-record-fields.ts b/packages/lint/src/validate-thank-you-record-fields.ts new file mode 100644 index 00000000000..014ba032cba --- /dev/null +++ b/packages/lint/src/validate-thank-you-record-fields.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A form's `thank-you` panel may name only a field the engine generates on + * insert (ruling B on #28005, ruling record 6545210989). + * + * The panel's `title` / `message` interpolate a field of the record just + * submitted as `{{record.field_name}}`; the spec door holds the token's shape + * (`FormViewSchema`, `ui/view.zod.ts`) and `thankYouRecordFieldNames` + * (`@objectstack/spec/ui`) is the one reading of which fields a panel names. + * What the shape cannot decide is whether a named field is one an anonymous + * visitor may be shown. The submit answer of a public form echoes nothing the + * insert stored, because a `beforeInsert` / `afterInsert` hook — an elevated + * one included — can derive a stored value from records the anonymous form + * grant may never read. So the answer carries `id` plus exactly the named + * fields (`PublicFormSubmitResponseSchema`, `@objectstack/spec/api`), and a + * name is admitted only when its field's type is in `RUNTIME_OWNED_FIELD_TYPES` + * (`@objectstack/spec/data`, today `autonumber`): the engine mints that value + * itself, so no hook could have put it there. ⛔ This rule carries no list of + * its own; widening that set widens what it admits, with no edit here. + * + * Every other name is refused where it is written, naming the field and its + * type: a declared field of another type, a registry-injected column + * (`created_at`, `owner_id`, `id`, … — injected, not engine-numbered), and a + * name the object does not declare at all (the panel would print nothing). + * + * ## Which forms + * + * Every form that carries a `thank-you` panel, public or not: the slot is one + * slot with one admission, and a form that is internal today is made public by + * flipping its `sharing`, which is a write this rule would otherwise never see + * again. + * + * - A `views[]` entry's forms: the bare form-view shape itself, the nested + * `form`, every `formViews` entry and the flattened `config` of a + * `viewKind: 'form'` item. The object is the one `anonymousFormObjectName` + * resolves, the rung order both anonymous doors submit by. + * - A page `object-form` component (`properties.submitBehavior`, the form + * view's own block by reference). Its object is its `properties.objectName`, + * else the binding the page walk resolves. + * + * A form whose object does not resolve in the stack is not judged here (the + * object-reference rules own that answer), the same boundary + * `validate-public-form-file-fields.ts` draws. + * + * ## Where it runs + * + * A stack-level authoring rule (`authoring-rules.ts`): `os validate`, + * `os build` and `os lint` run it over the whole stack, and the runtime publish + * gate runs it on a `view` or `page` write, whose snapshot carries the + * `objects` collection as resolution context. An `object` write's snapshot + * carries no views or pages, so retyping a named field under a published panel + * is caught by the CLI commands and by the form's next publish. + */ + +import { RUNTIME_OWNED_FIELD_TYPES } from '@objectstack/spec/data'; +import { anonymousFormObjectName, thankYouRecordFieldNames } from '@objectstack/spec/ui'; + +import { collectionEntries } from './collection-entries.js'; +import { indexObjectGraph, type GraphObject } from './object-graph.js'; +import { walkPageComponents } from './page-walk.js'; + +export const THANK_YOU_RECORD_FIELD = 'thank-you-record-field'; + +export interface ThankYouRecordFieldFinding { + severity: 'error'; + /** Diagnostic rule id: always {@link THANK_YOU_RECORD_FIELD}. */ + rule: string; + /** Human-readable location, e.g. `view "case_intake" · form`. */ + where: string; + /** Config path of the panel, e.g. `views[0].form.submitBehavior`. */ + path: string; + message: string; + hint: string; +} + +type AnyRec = Record; + +/** The page component type whose `properties.submitBehavior` is a form view's block. */ +const OBJECT_FORM_COMPONENT_TYPE = 'object-form'; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** One form that carries a panel, located. */ +interface PanelSite { + submitBehavior: unknown; + objectName: string | undefined; + where: string; + path: string; + /** Who reads the refusal: `view "x" · form`, `page "y" · component …`. */ + owner: string; +} + +/** Every form a `views[]` entry carries, in scan order, with its slot. */ +function viewForms(view: AnyRec): Array<{ form: AnyRec; slot: string }> { + const forms: Array<{ form: AnyRec; slot: string }> = []; + if ('submitBehavior' in view) forms.push({ form: view, slot: '' }); + if (isRec(view.form)) forms.push({ form: view.form, slot: 'form' }); + if (isRec(view.formViews)) { + for (const [key, fv] of Object.entries(view.formViews)) { + if (isRec(fv)) forms.push({ form: fv, slot: `formViews.${key}` }); + } + } + if (view.viewKind === 'form' && isRec(view.config)) forms.push({ form: view.config, slot: 'config' }); + return forms; +} + +function panelSites(stack: AnyRec): PanelSite[] { + const sites: PanelSite[] = []; + for (const { rec: view, path: viewPath } of collectionEntries(stack.views, 'views')) { + const viewName = strName(view.name) ?? strName(view.object) ?? viewPath; + for (const { form, slot } of viewForms(view)) { + if (form.submitBehavior === undefined) continue; + const owner = `view "${viewName}"${slot ? ` · ${slot}` : ''}`; + sites.push({ + submitBehavior: form.submitBehavior, + objectName: anonymousFormObjectName(view, form), + where: owner, + path: `${viewPath}${slot ? `.${slot}` : ''}.submitBehavior`, + owner, + }); + } + } + for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) { + const pageName = strName(page.name) ?? pagePath; + for (const walked of walkPageComponents(page, pagePath)) { + const { component } = walked; + if (component.type !== OBJECT_FORM_COMPONENT_TYPE || !isRec(component.properties)) continue; + if (component.properties.submitBehavior === undefined) continue; + const id = strName(component.id); + const owner = `page "${pageName}" · object-form${id ? ` "${id}"` : ` at ${walked.path}`}`; + sites.push({ + submitBehavior: component.properties.submitBehavior, + objectName: strName(component.properties.objectName) ?? walked.objectName, + where: owner, + path: `${walked.path}.properties.submitBehavior`, + owner, + }); + } + } + return sites; +} + +/** Why `name` is not admitted on `object`, or `undefined` when it is runtime-owned. */ +function refusalReason(object: GraphObject, name: string, admitted: ReadonlySet): string | undefined { + const declared = object.fields.get(name); + if (declared) { + const type = declared.type; + if (type !== undefined && admitted.has(type)) return undefined; + return `a ${type ?? 'untyped'} field`; + } + if (object.injected.has(name)) { + const type = object.injected.get(name)?.type; + return `a system column${type ? ` (${type})` : ''} the platform adds to every record`; + } + return 'not a field the object declares'; +} + +/** + * Refuse every name a `thank-you` panel interpolates that is not an + * engine-generated field. Empty = clean. + * + * `admitted` is the spec's set and nothing else in production; the parameter + * exists so a test can widen it as a double and prove this rule follows the + * one source with no edit of its own (the spec dist is externalised in this + * package's tests, so it cannot be module-mocked). + */ +export function validateThankYouRecordFields( + stack: AnyRec, + admitted: ReadonlySet = RUNTIME_OWNED_FIELD_TYPES, +): ThankYouRecordFieldFinding[] { + const findings: ThankYouRecordFieldFinding[] = []; + const sites = panelSites(stack); + if (sites.length === 0) return findings; + const graph = indexObjectGraph(stack); + + for (const site of sites) { + const names = thankYouRecordFieldNames(site.submitBehavior); + if (names.length === 0 || !site.objectName) continue; + const object = graph.get(site.objectName); + if (!object) continue; + for (const name of names) { + const reason = refusalReason(object, name, admitted); + if (reason === undefined) continue; + findings.push({ + severity: 'error', + rule: THANK_YOU_RECORD_FIELD, + where: site.where, + path: site.path, + message: + `${site.owner}: the thank-you panel names {{record.${name}}}, which on object ` + + `"${site.objectName}" is ${reason}. A thank-you panel may name only a field the engine ` + + `generates on insert: any other value may have been set by a beforeInsert / afterInsert ` + + `hook from records an anonymous visitor's form grant cannot read, so a public form's ` + + `submit answer never carries it.`, + hint: + `Name a ${[...admitted].join(' or ')} field of "${site.objectName}" instead ` + + `(for example a case number), or ` + + `take {{record.${name}}} out of the panel's title and message.`, + }); + } + } + + return findings; +} diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 1868fc7bca8..5f15a205f88 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -802,6 +802,8 @@ "PresignedUrlResponseSchema (const)", "ProvenanceWaiver (type)", "ProvenanceWaiverSchema (const)", + "PublicFormSubmitResponse (type)", + "PublicFormSubmitResponseSchema (const)", "PublishMetaItemRequest (type)", "PublishMetaItemRequestSchema (const)", "PublishMetaItemResponse (type)", diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 1944a4120af..68448dd4ad3 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -584,6 +584,7 @@ "resolveI18nLabel (function)", "selectViewMetadataBranch (function)", "stripViewConsoleDecorations (function)", + "thankYouRecordFieldNames (function)", "validateActionParams (function)", "viewForm (const)" ] diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index bc78a34f0f7..605b34d1be2 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1356,6 +1356,8 @@ "api/ProvenanceWaiver:package", "api/ProvenanceWaiver:reason", "api/ProvenanceWaiver:registeredUnder", + "api/PublicFormSubmitResponse:fields", + "api/PublicFormSubmitResponse:id", "api/PublishMetaItemRequest:actor", "api/PublishMetaItemRequest:member", "api/PublishMetaItemRequest:message", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 3f176d796ce..64cdd51cec7 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -585,6 +585,8 @@ "PresignedUrlResponseSchema": "api/PresignedUrlResponse", "ProvenanceWaiver": "api/ProvenanceWaiver", "ProvenanceWaiverSchema": "api/ProvenanceWaiver", + "PublicFormSubmitResponse": "api/PublicFormSubmitResponse", + "PublicFormSubmitResponseSchema": "api/PublicFormSubmitResponse", "PublishMetaItemRequest": "api/PublishMetaItemRequest", "PublishMetaItemRequestSchema": "api/PublishMetaItemRequest", "PublishMetaItemResponse": "api/PublishMetaItemResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 2d5dbd42677..7f55a7bf09e 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -773,6 +773,8 @@ "PresignedUrlResponseSchema": "src/api/storage.zod.ts#PresignedUrlResponseSchema (const)", "ProvenanceWaiver": "src/api/error-code-ledger.zod.ts#ProvenanceWaiver (type)", "ProvenanceWaiverSchema": "src/api/error-code-ledger.zod.ts#ProvenanceWaiverSchema (const)", + "PublicFormSubmitResponse": "src/api/protocol.zod.ts#PublicFormSubmitResponse (type)", + "PublicFormSubmitResponseSchema": "src/api/protocol.zod.ts#PublicFormSubmitResponseSchema (const)", "PublishMetaItemRequest": "src/api/protocol.zod.ts#PublishMetaItemRequest (type)", "PublishMetaItemRequestSchema": "src/api/protocol.zod.ts#PublishMetaItemRequestSchema (const)", "PublishMetaItemResponse": "src/api/protocol.zod.ts#PublishMetaItemResponse (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 34da78d93a2..fac837449a4 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -567,6 +567,7 @@ "resolveI18nLabel": "src/ui/i18n-label-resolver.ts#resolveI18nLabel (function)", "selectViewMetadataBranch": "src/ui/view.zod.ts#selectViewMetadataBranch (function)", "stripViewConsoleDecorations": "src/ui/view.zod.ts#stripViewConsoleDecorations (function)", + "thankYouRecordFieldNames": "src/ui/view.zod.ts#thankYouRecordFieldNames (function)", "validateActionParams": "src/ui/action-params.zod.ts#validateActionParams (function)", "viewForm": "src/ui/view.form.ts#viewForm (const)" } diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 6335b4d7657..256bdd1570c 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -320,6 +320,7 @@ "api/PresenceUpdate", "api/PresignedUrlResponse", "api/ProvenanceWaiver", + "api/PublicFormSubmitResponse", "api/PublishMetaItemRequest", "api/PublishMetaItemResponse", "api/PublishPackageDraftsRequest", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 87c51f9750f..869eab836cd 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2740,6 +2740,34 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({ notice: z.string().min(1).optional().describe(WRITE_ANSWER_NOTICE_DESCRIPTION), })); +/** + * Public Form Submit Response (#28005) + * + * The WHOLE body of `POST /forms/:slug/submit`, the anonymous public-form + * submission: the created record's `id`, plus — only when the form's + * `thank-you` panel names fields of the record as `{{record.field_name}}` — + * `fields`, holding exactly those fields' stored values. + * + * The answer echoes nothing else the insert stored, and that is the point of + * the shape. The stored row can carry values a `beforeInsert` / `afterInsert` + * hook derived, an elevated (`runAs: 'system'`) one included, from records + * the anonymous form grant may never read. The panel may therefore name only + * fields the ENGINE generates on insert (`RUNTIME_OWNED_FIELD_TYPES`, today + * `autonumber`); the authoring rule that judges a form's names refuses any + * other, and `thankYouRecordFieldNames` (`ui/view.zod.ts`) is the one reading + * of which names a panel holds. Strict, so a key beyond these two is a + * conformance failure rather than a silent widening of what an anonymous + * caller is shown. + */ +export const PublicFormSubmitResponseSchema = lazySchema(() => z.object({ + id: z.string().describe('The ID of the newly created record.'), + fields: z.record(z.string(), z.union([z.string(), z.number()])).optional().describe( + 'The stored values of exactly the fields the form\'s `thank-you` panel names as `{{record.field_name}}`, ' + + 'keyed by field name — each one a field the engine generates on insert (an `autonumber` field, e.g. a case ' + + 'number). Present only when the panel names at least one; never any other field of the record.', + ), +}).strict()); + /** * Clone Data Response (#11924) * @@ -4119,6 +4147,8 @@ export type GetDataRequest = z.input; export type GetDataResponse = z.input; export type CreateDataRequest = z.input; export type CreateDataResponse = z.input; +/** The `POST /forms/:slug/submit` body — see {@link PublicFormSubmitResponseSchema} (#28005). */ +export type PublicFormSubmitResponse = z.input; /** The `POST /data/:object/:id/clone` body — see {@link CloneDataResponseSchema} (#11924). */ export type CloneDataResponse = z.input; export type ValidateDataIssue = z.input; diff --git a/packages/spec/src/api/public-form-submit-response.test.ts b/packages/spec/src/api/public-form-submit-response.test.ts new file mode 100644 index 00000000000..5f58d6a60b8 --- /dev/null +++ b/packages/spec/src/api/public-form-submit-response.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #28005 (ruling B) — the anonymous public-form submit answer: `id`, plus the + * engine-generated fields the form's `thank-you` panel names, and nothing else. + */ + +import { describe, it, expect } from 'vitest'; + +import { PublicFormSubmitResponseSchema } from './protocol.zod'; + +describe('PublicFormSubmitResponseSchema', () => { + it('accepts the id-only answer', () => { + expect(PublicFormSubmitResponseSchema.safeParse({ id: 'rec_1' }).success).toBe(true); + }); + + it('accepts the id plus the named fields', () => { + const answer = { id: 'rec_1', fields: { case_number: 'CASE-00042' } }; + const r = PublicFormSubmitResponseSchema.safeParse(answer); + expect(r.success).toBe(true); + expect(r.data).toEqual(answer); + }); + + it('refuses any other top-level key — the stored record never rides along', () => { + const r = PublicFormSubmitResponseSchema.safeParse({ id: 'rec_1', record: { subject: 'x' } }); + expect(r.success).toBe(false); + expect(r.error?.issues[0]?.code).toBe('unrecognized_keys'); + }); + + it('refuses an answer without an id, and a non-scalar field value', () => { + expect(PublicFormSubmitResponseSchema.safeParse({ fields: { case_number: 'C-1' } }).success).toBe(false); + expect(PublicFormSubmitResponseSchema.safeParse({ id: 'r', fields: { case_number: { v: 1 } } }).success) + .toBe(false); + }); +}); diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 90fb21a7b90..8343f3d47b2 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -285,7 +285,7 @@ import type * as M194 from './automation/assignment-rule.zod.js'; import type * as M195 from './security/queue.zod.js'; // --------------------------------------------------------------------------- -// 806 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 807 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 @@ -616,6 +616,7 @@ export type Iso_api_protocol__MarkNotificationsReadRequestSchema = Assert, z.infer< typeof M28.MarkNotificationsReadResponseSchema > >>; export type Iso_api_protocol__MetadataAuthoringClientSchema = Assert, z.infer< typeof M28.MetadataAuthoringClientSchema > >>; export type Iso_api_protocol__MetadataAuthoringSurfaceSchema = Assert, z.infer< typeof M28.MetadataAuthoringSurfaceSchema > >>; +export type Iso_api_protocol__PublicFormSubmitResponseSchema = Assert, z.infer< typeof M28.PublicFormSubmitResponseSchema > >>; export type Iso_api_protocol__PublishMetaItemRequestSchema = Assert, z.infer< typeof M28.PublishMetaItemRequestSchema > >>; export type Iso_api_protocol__PublishMetaItemResponseSchema = Assert, z.infer< typeof M28.PublishMetaItemResponseSchema > >>; export type Iso_api_protocol__PublishPackageDraftsRequestSchema = Assert, z.infer< typeof M28.PublishPackageDraftsRequestSchema > >>; @@ -1740,7 +1741,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 806 isomorphic pins', () => { + it('still declares all 807 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 @@ -2707,7 +2708,10 @@ describe('ADR-0122 type-alias convention', () => { // strict object of two `z.string().min(1)`s, no `.default()`, // `.transform()`, `.catch()` or `.pipe()` // (Iso_api_protocol__PublishPackageDraftsRequestSchema). +1 added. - expect(pins).toHaveLength(806); + // 806 -> 807 is #28005: the public-form submit answer + // (`api/protocol.zod.ts`), strict, no default and no transform + // (Iso_api_protocol__PublicFormSubmitResponseSchema). +1 added. + expect(pins).toHaveLength(807); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/packages/spec/src/ui/view-submit-thank-you-record-slot.test.ts b/packages/spec/src/ui/view-submit-thank-you-record-slot.test.ts new file mode 100644 index 00000000000..a44aedd6d4b --- /dev/null +++ b/packages/spec/src/ui/view-submit-thank-you-record-slot.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #28005 (ruling B) — the `thank-you` panel's record slot, pinned at the schema + * door. + * + * The panel's `title` / `message` take the redirect's token, + * `{{record.field_name}}`, and nothing else spelled with a brace. Parse time + * judges the SHAPE only; which field a token may name (engine-generated only) + * is the lint rule's, where the form's object is known + * (`@objectstack/lint` `validate-thank-you-record-fields.test.ts`). + * + * Each refusal pins the guidance an author reads — the offending token and the + * one spelling to write — not merely that the parse failed. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { FormViewSchema, thankYouRecordFieldNames } from './view.zod'; +import { projectPublishedJsonSchema } from '../../scripts/lib/refinement-projection'; + +const FORM_BASE = { type: 'simple', sections: [{ fields: ['subject'] }] }; + +function form(panel: Record): unknown { + return { ...FORM_BASE, submitBehavior: { kind: 'thank-you', ...panel } }; +} + +function accept(value: unknown): void { + const r = FormViewSchema.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify(r.error?.issues ?? '')}`).toBe(true); +} + +/** Reject `value` and return its issues, each a path + message. */ +function reject(value: unknown): Array<{ path: PropertyKey[]; message: string }> { + const r = FormViewSchema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return (r.error?.issues ?? []).map((i) => ({ path: i.path, message: i.message })); +} + +describe('thank-you panel text takes `{{record.field_name}}` and no other brace', () => { + it('a message naming a case number parses, and the text arrives verbatim', () => { + const value = form({ title: 'Thanks', message: 'Your case number is {{record.case_number}}.' }); + accept(value); + const parsed = FormViewSchema.parse(value) as { submitBehavior?: { message?: string } }; + expect(parsed.submitBehavior?.message).toBe('Your case number is {{record.case_number}}.'); + }); + + it('a title token, several tokens, and text with no token all parse', () => { + accept(form({ title: 'Case {{record.case_number}} received' })); + accept(form({ message: '{{record.case_number}} / {{record.ticket_no}}' })); + accept(form({ title: 'Thank you!', message: 'We will be in touch.' })); + accept(form({})); + }); + + it.each([ + ['single braces', 'Your case is {record.case_number}', '{record.case_number}'], + ['spaces inside the braces', 'Your case is {{ record.case_number }}', '{{ record.case_number }}'], + ['no `record.` root', 'Your case is {{case_number}}', '{{case_number}}'], + ['an uppercase field segment', 'Your case is {{record.CaseNumber}}', '{{record.CaseNumber}}'], + ['a dotted path', 'Your case is {{record.account.name}}', '{{record.account.name}}'], + ['a stray closing brace', 'Done }', '}'], + ])('refuses %s, naming the offender and the one spelling', (_label, text, offender) => { + const issues = reject(form({ message: text })); + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['submitBehavior', 'message']); + expect(issues[0].message).toContain(`\`${offender}\` is not that shape`); + expect(issues[0].message).toContain('`{{record.field_name}}`'); + }); + + it('refuses a malformed title on the title, and names the member', () => { + const issues = reject(form({ title: 'Case {{record.Case}}' })); + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['submitBehavior', 'title']); + expect(issues[0].message).toMatch(/^A `thank-you` panel's `title`/); + }); + + it('the refusal carries no tracker id and no ruling date', () => { + const [issue] = reject(form({ message: '{case}' })); + expect(issue.message).not.toMatch(/#\d{3,6}|\b20\d\d-\d\d-\d\d\b|\bruling\b/i); + }); + + it('the published JSON Schema carries the shape as a `pattern` (no dropped refinement)', () => { + const json = JSON.stringify( + projectPublishedJsonSchema(FormViewSchema as unknown as z.ZodType, { io: 'input', unrepresentable: 'any' }), + ); + expect(json).toContain(JSON.stringify('^(?:[^{}]|\\{\\{record\\.[a-z_][a-z0-9_]*\\}\\})*$')); + }); +}); + +describe('thankYouRecordFieldNames — the one reading of which fields a panel names', () => { + it('reads title then message, each name once, in order of first appearance', () => { + expect(thankYouRecordFieldNames({ + kind: 'thank-you', + title: 'Case {{record.case_number}}', + message: '{{record.ticket_no}} and again {{record.case_number}}', + })).toEqual(['case_number', 'ticket_no']); + }); + + it('answers [] for any other kind, no panel text, no token, and non-objects', () => { + expect(thankYouRecordFieldNames({ kind: 'redirect', url: '/thanks?c={{record.case_number}}' })).toEqual([]); + expect(thankYouRecordFieldNames({ kind: 'thank-you' })).toEqual([]); + expect(thankYouRecordFieldNames({ kind: 'thank-you', message: 'Thanks!' })).toEqual([]); + expect(thankYouRecordFieldNames(undefined)).toEqual([]); + expect(thankYouRecordFieldNames('thank-you')).toEqual([]); + }); + + it('reads only well-formed tokens', () => { + expect(thankYouRecordFieldNames({ kind: 'thank-you', message: '{{ record.a }} {record.b} {{record.c}}' })) + .toEqual(['c']); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 34a03f8645e..0476f5bc570 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4724,6 +4724,88 @@ function checkSubmitRedirectUrl(raw: string): string | undefined { return undefined; } +/** + * ## The `thank-you` panel's record slot + * + * A public form's visitor often needs a reference once the form is sent — a + * web-to-case visitor needs the case number. The panel's `title` and `message` + * therefore take the SAME token the `redirect` member takes, + * `{{record.field_name}}` ({@link SUBMIT_REDIRECT_URL_TOKEN_RE}): one grammar + * for "a field of the record just submitted", never a second dialect. + * + * What the token may NAME is narrower than for a redirect. The anonymous submit + * answer echoes nothing the insert stored, because a `beforeInsert` / + * `afterInsert` hook — an elevated one included — can derive a stored value from + * records the anonymous form grant may never read. The panel may therefore name + * only a field whose value the ENGINE generates on insert: a field whose type is + * in `RUNTIME_OWNED_FIELD_TYPES` (`data/field.zod.ts`, today `autonumber`), the + * one statement of that set. The submit answer carries `id` plus exactly those + * named fields (`PublicFormSubmitResponseSchema`, `api/protocol.zod.ts`). + * + * Parse time knows the string and not the object, so the schema enforces the + * token SHAPE only — a malformed token is refused on its own terms, as the + * redirect refine does. Whether the named field is engine-generated is judged + * where the form's object is known: `@objectstack/lint`'s + * `validateThankYouRecordFields`, a gating authoring rule (`os validate` / + * `os build` / `os lint`, and the runtime publish gate on a `view` or `page` + * write). + */ +// The ruling is #28005 on the tracker, option B, ruled 2026-10-31 (ruling +// record 6545210989). Internal readers get the id and the date here; the +// customer-facing sentences carry neither (#22093). + +/** + * A `thank-you` panel text in which every brace belongs to a well-formed + * `{{record.field_name}}` token — the token of {@link SUBMIT_REDIRECT_URL_TOKEN_RE}, + * spelled again only because a `pattern` must be anchored and repeated. The two + * alternatives start on disjoint characters, so matching is linear. + */ +const THANK_YOU_TEXT_RE = /^(?:[^{}]|\{\{record\.[a-z_][a-z0-9_]*\}\})*$/; + +/** + * Word the refusal for one `thank-you` panel text that fails + * {@link THANK_YOU_TEXT_RE}. Returns the author-facing refusal, or `undefined` + * when every brace in it belongs to a well-formed `{{record.field_name}}` token. + */ +function checkThankYouText(raw: string, member: 'title' | 'message'): string | undefined { + const withoutTokens = raw.replace(SUBMIT_REDIRECT_URL_TOKEN_RE, ''); + if (!withoutTokens.includes('{') && !withoutTokens.includes('}')) return undefined; + const offender = withoutTokens.match(/\{\{?[^{}]*\}?\}?/)?.[0] ?? '}'; + return `A \`thank-you\` panel's \`${member}\` names a field of the submitted record ONLY as ` + + `\`{{record.field_name}}\` — \`${offender}\` is not that shape. Write the field's name in lowercase ` + + 'snake_case after `record.`, with no spaces inside the braces (`{{record.case_number}}`). Braces ' + + 'belong to that token alone, so reword text that needs a literal brace. Only a field the engine ' + + 'generates on insert (an `autonumber` field) may be named.'; +} + +/** + * The field names a `submitBehavior` block's `thank-you` panel interpolates as + * `{{record.field_name}}`, across `title` then `message`, each named once in + * order of first appearance. Any other kind, or a panel with no token, answers + * `[]`. + * + * This is the one reading of which fields the panel names: the authoring rule + * judges exactly these names, and the anonymous submit answer carries exactly + * these fields (`PublicFormSubmitResponseSchema.fields`). A pure function of the + * block — no schema, no object — so a server door and a renderer call the same + * code instead of each re-reading the token. + */ +export function thankYouRecordFieldNames(submitBehavior: unknown): string[] { + if (!submitBehavior || typeof submitBehavior !== 'object') return []; + const block = submitBehavior as { kind?: unknown; title?: unknown; message?: unknown }; + if (block.kind !== 'thank-you') return []; + const names: string[] = []; + for (const text of [block.title, block.message]) { + if (typeof text !== 'string') continue; + for (const token of text.matchAll(SUBMIT_REDIRECT_URL_TOKEN_RE)) { + // `{{record.` is 9 characters and `}}` is 2: the field segment between. + const name = token[0].slice(9, -2); + if (!names.includes(name)) names.push(name); + } + } + return names; +} + /** * ## Form-view predicates may not name the `features.*` scope root * @@ -5185,8 +5267,28 @@ export const FormViewSchema = lazySchema(() => strictObject({ aliases: { heading: 'title', text: 'message', body: 'message', description: 'message' }, }, { kind: z.literal('thank-you'), - title: z.string().optional(), - message: z.string().optional(), + // [#28005] `z.string()` stays the type, as on the redirect's `url`: the + // renderer receives the text the author wrote and fills each token from + // the submit answer. The shape is a `.regex()`, not a refine, so the + // published JSON Schema carries it as a `pattern` too; `checkThankYouText` + // words the refusal, and `thankYouRecordFieldNames` reads which fields a + // text names. + title: z.string() + .regex(THANK_YOU_TEXT_RE, { error: (issue) => checkThankYouText(String(issue.input), 'title') }) + .optional() + .describe( + 'Heading of the confirmation panel. May name a field of the submitted record as ' + + '`{{record.field_name}}`, and only a field the engine generates on insert (an `autonumber` ' + + 'field, e.g. a case number): a hook-derived value never reaches an anonymous visitor.', + ), + message: z.string() + .regex(THANK_YOU_TEXT_RE, { error: (issue) => checkThankYouText(String(issue.input), 'message') }) + .optional() + .describe( + 'Body of the confirmation panel. May name a field of the submitted record as ' + + '`{{record.field_name}}`, and only a field the engine generates on insert (an `autonumber` ' + + 'field, e.g. a case number): a hook-derived value never reaches an anonymous visitor.', + ), }), strictObject({ surface: 'this `redirect` submit behavior',