diff --git a/.changeset/23316-refused-flow-registration-verdict.md b/.changeset/23316-refused-flow-registration-verdict.md new file mode 100644 index 00000000000..1cf8c4033c1 --- /dev/null +++ b/.changeset/23316-refused-flow-registration-verdict.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/service-automation': minor +'@objectstack/rest': minor +--- + +A stored flow the automation engine refuses is no longer served as running: the flow meta read serves the engine's verdict beside the item as `registration`, an envelope sibling of `lock`, and the flow list serves it as `registrations` keyed by flow name, so a refused flow reads `{ state: 'refused', reason: { code?, message } }` beside its stored `status`, which stays as authored. + +Clause-②: yes (widening) + +- **The shape** (`@objectstack/spec`). `FlowRegistrationSchema` is a closed set: `registered` (the engine holds the flow), `refused` (it refused the stored flow and holds nothing under the name; `reason` carries the engine's own `message` and, when the thrown error set one, its `code`, verbatim), and `unregistered` (it holds nothing and refused nothing: a read during boot, between a save and the engine following it, or after a deliberate unregister). `GetMetaItemResponseSchema` gains `registration` and `GetMetaItemsResponseSchema` gains `registrations`, both optional. New exports: `FlowRegistrationSchema`, `FlowRegistrationRefusalSchema` and the types `FlowRegistration` and `FlowRegistrationRefusal`. `IAutomationService` gains the optional member `getFlowRegistration(name)`. +- **Where the verdict comes from** (`@objectstack/service-automation`). The `kernel:ready` cold-boot bind records each flow it refuses, with what the engine threw, after it withdraws the flow; the `metadata:reloaded` re-sync and the metadata-change sync record a refusal that leaves the name unregistered. A later successful registration of the name, or its withdrawal, ends the record. `AutomationEngine` answers `getFlowRegistration(name)` and takes `recordFlowRefusal(name, error)`. The refusal's warn line, its level and the withdrawal are unchanged. +- **Where it is served** (`@objectstack/metadata-protocol`). The protocol reads the engine through the kernel's service table by name, as it already reads the engine's stored-flow canonicalizer; it does not import the automation service. The field is absent where no automation service declares the member, on every type other than `flow`, on a draft read and on the execution face the engine binds from: absence means no engine judged the flow, never `registered`. The verdict is the engine of the process that answered the read. +- **The default door** (`@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/spec`). `GET /api/v1/meta/flow/:name` on a default deployment (`metadata.enableCache: true`) is answered by the cached item branch, which now carries `registration` too. `getMetaItemCached` returns it beside `data`, read live from its delegate read on every call, and `GetMetaItemCachedResponseSchema` declares it. It is never part of the ETag, so a cached read that carries it is never answered `304 Not Modified`: a judged flow is always served in full. The cached branch's other envelope keys are unchanged. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 3a5c70f8a93..f71dd588b46 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2133,6 +2133,24 @@ The plugin is a **soft dependency** on `metadata` — it tolerates running without `MetadataPlugin` and it logs (not throws) on per-flow registration failures so one broken flow does not abort startup. +A flow the engine refuses is not served as running. The metadata read keeps +the stored `status` as you wrote it, and serves the engine's verdict beside +the item: `registration` on `GET /api/v1/meta/flow/:name`, and `registrations`, +keyed by flow name, on `GET /api/v1/meta/flow`. The verdict is one of three: + +| `state` | Meaning | +|:---|:---| +| `registered` | The engine holds the flow. Whether its trigger is bound is `GET /api/v1/automation/_status`'s answer. | +| `refused` | The engine refused the stored flow and holds nothing under its name, so it never fires. `reason` is what the engine threw, verbatim: its `message`, and its `code` when it set one. | +| `unregistered` | The engine holds nothing under the name and refused nothing: the read came during boot, or between a save and the engine following it, or the flow was unregistered on purpose. | + +A save, a publish or a reload that registers a corrected body ends the +`refused` state. The field is absent where no automation service answered the +read. The item read carries it on the default cached read as well as the +uncached one: it is read live on every call and kept out of the ETag, so a +read that carries it is always answered in full, never with `304 Not +Modified`. It is the verdict of the engine in the process that answered. + ## Expressions in flows A flow computes with **one expression dialect, CEL**: every condition is bare diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index b15cd76bbc7..c83fd2fc8bb 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 134 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 136 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, 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, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, 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, 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, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, 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, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, 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, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, 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); @@ -967,6 +967,65 @@ Enable package response | **kind** | `string` | ✅ | What kind of reference this is (e.g. which key carries it). | +--- + +## FlowRegistration + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'registered'` | ✅ | The engine holds a registration under the flow's name. | + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'refused'` | ✅ | The engine refused to register the stored flow and holds nothing under its name: it never runs. | +| **reason** | `{ code?: string; message: string }` | ✅ | What the engine threw, verbatim. | + +### Nested Shape: `FlowRegistration[state='refused'].reason` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | optional | The machine code the engine's refusal carried, verbatim. Absent when the thrown error carried none. | +| **message** | `string` | ✅ | The engine's refusal message, verbatim. Render it as sent; do not parse it. | + +--- + +#### Option 3 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'unregistered'` | ✅ | The engine holds nothing under the flow's name and recorded no refusal for it. | + +--- + + +--- + +## FlowRegistrationRefusal + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | optional | The machine code the engine's refusal carried, verbatim. Absent when the thrown error carried none. | +| **message** | `string` | ✅ | The engine's refusal message, verbatim. Render it as sent; do not parse it. | + + --- ## GetDataRequest @@ -1270,6 +1329,7 @@ Enable package response | **cacheControl** | `{ directives: Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]; maxAge?: number; staleWhileRevalidate?: number; staleIfError?: number }` | optional | Cache control directives | | **notModified** | `boolean` | optional (default: `false`) | True if resource has not been modified (304 response) | | **version** | `string` | optional | Metadata version identifier | +| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on the flow in `data`, read live on every call and never part of `etag` (see `FlowRegistrationSchema`). Present only for a `flow` read answered by a process that runs an automation service; a read that carries it is never answered `notModified`. Absent otherwise, and absence means no engine judged it, never `registered`. | ### Nested Shape: `GetMetaItemCachedResponse.etag` @@ -1287,6 +1347,25 @@ Enable package response | **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) | | **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) | +### Nested Shape: `GetMetaItemCachedResponse.registration[state='registered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'registered'` | ✅ | The engine holds a registration under the flow's name. | + +### Nested Shape: `GetMetaItemCachedResponse.registration[state='refused']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'refused'` | ✅ | The engine refused to register the stored flow and holds nothing under its name: it never runs. | +| **reason** | `{ code?: string; message: string }` | ✅ | What the engine threw, verbatim. | + +### Nested Shape: `GetMetaItemCachedResponse.registration[state='unregistered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'unregistered'` | ✅ | The engine holds nothing under the flow's name and recorded no refusal for it. | + --- @@ -1375,6 +1454,7 @@ Enable package response | **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | | **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | +| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | ### Nested Shape: `GetMetaItemResponse.sortability` @@ -1382,6 +1462,25 @@ Enable package response | :--- | :--- | :--- | :--- | | **fields** | `Record` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. | +### Nested Shape: `GetMetaItemResponse.registration[state='registered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'registered'` | ✅ | The engine holds a registration under the flow's name. | + +### Nested Shape: `GetMetaItemResponse.registration[state='refused']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'refused'` | ✅ | The engine refused to register the stored flow and holds nothing under its name: it never runs. | +| **reason** | `{ code?: string; message: string }` | ✅ | What the engine threw, verbatim. | + +### Nested Shape: `GetMetaItemResponse.registration[state='unregistered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'unregistered'` | ✅ | The engine holds nothing under the flow's name and recorded no refusal for it. | + --- @@ -1406,6 +1505,26 @@ Enable package response | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **items** | `any[]` | ✅ | Array of metadata items | +| **registrations** | `Record` | optional | The automation engine's verdict on each listed flow, keyed by the item's `name`: one entry per listed item that is not a pending draft. Present only on a `flow` list answered by a process that runs an automation service; absent otherwise, and absence means no engine judged them, never `registered`. The verdict is the answering process's engine. Beside the items, never inside one: an item's `status` stays what its author declared. | + +### Nested Shape: `GetMetaItemsResponse.registrations[string][state='registered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'registered'` | ✅ | The engine holds a registration under the flow's name. | + +### Nested Shape: `GetMetaItemsResponse.registrations[string][state='refused']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'refused'` | ✅ | The engine refused to register the stored flow and holds nothing under its name: it never runs. | +| **reason** | `{ code?: string; message: string }` | ✅ | What the engine threw, verbatim. | + +### Nested Shape: `GetMetaItemsResponse.registrations[string][state='unregistered']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **state** | `'unregistered'` | ✅ | The engine holds nothing under the flow's name and recorded no refusal for it. | --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 196a6a75967..890721ef50f 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 — 1531 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1533 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) | 32 | 434 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 71 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 176 | 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) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 167 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1531** | 14 protocol modules | +| **Total** | **197** | **1533** | 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` · **32 pages, 434 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 436 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -91,7 +91,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`, `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`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `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`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `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/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index e7705b79d71..a09c0025fd7 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/` | 438 | +| `api/` | 442 | diff --git a/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts b/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts index 97c8a58c777..07da568ae15 100644 --- a/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts @@ -27,8 +27,9 @@ * organization-scoped flow row is out of the read's reach, and a type in the * overlay regime keeps its overlay. */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import type { FlowRegistration } from '@objectstack/spec/api'; import { ObjectStackProtocolImplementation } from './protocol.js'; const PACKAGE_ID = 'com.example.pkg'; @@ -134,7 +135,7 @@ function registryDouble() { * registry. ⛔ No `insert` / `update` / `delete` — the read paths under test * issue no write verb. */ -function harness(rows: StoredRow[]) { +function harness(rows: StoredRow[], services: Map = new Map()) { const registry = registryDouble(); // What the loader registered: one packaged flow and one packaged view. registry.registerItem('flow', flowBody(SHIPPED, 'LOADER'), 'name', PACKAGE_ID); @@ -162,7 +163,8 @@ function harness(rows: StoredRow[]) { }, registry, }; - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()); + // [#23316] The kernel's service table — empty unless a case hands one in. + const protocol = new ObjectStackProtocolImplementation(engine, () => services as Map); return { protocol, registry }; } @@ -270,3 +272,154 @@ describe('[#20946] the by-name read of a shipped flow name serves the loader\'s expect(served._packageId).toBe(PACKAGE_ID); }); }); + +/** + * [#23316] The meta read serves the automation engine's verdict on a stored + * flow BESIDE the item, read from the kernel's service table by name: + * `registration`, an envelope sibling of `lock` on the item read, and + * `registrations`, keyed by name, beside the list's items. Both are the + * engine's answer (`IAutomationService.getFlowRegistration`) or nothing — the + * protocol never makes one up — so a host with no automation service, or a + * type the engine does not judge, reads no field at all, and the stored body, + * its `status` included, is served as stored. + * + * In this file for its engine double (the read paths under test are the ones + * above). The real composition — ObjectQL's protocol over SQLite and the real + * engine refusing at the `kernel:ready` bind — is pinned in + * service-automation's `flow-refused-registration-read.integration.test.ts`. + */ +const REFUSED: FlowRegistration = { state: 'refused', reason: { code: 'SOME_ENGINE_CODE', message: 'the engine said no' } }; +const VERDICT_ROWS = [ + storedRow('flow', 'refused_flow', flowBody('refused_flow', 'REFUSED', { status: 'active' })), + storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'CUSTOMER', { status: 'active' })), +]; + +/** The engine's answers, per name; any other name reads `unregistered`. */ +function automationDouble(answers: Record) { + return { + getFlowRegistration: vi.fn((name: string): FlowRegistration => answers[name] ?? { state: 'unregistered' }), + }; +} + +describe('[#23316] the item read: `registration` beside `lock`, the engine\'s answer or nothing', () => { + it('serves what the engine answered for that name, and the stored body unchanged', async () => { + const automation = automationDouble({ refused_flow: REFUSED }); + const { protocol } = harness(VERDICT_ROWS, new Map([['automation', automation]])); + + const read: any = await protocol.getMetaItem({ type: 'flow', name: 'refused_flow' }); + + expect(read.registration).toEqual(REFUSED); + expect(automation.getFlowRegistration).toHaveBeenCalledWith('refused_flow'); + // Beside the item, never inside it — and the author's `status` as stored. + expect(read.item.status).toBe('active'); + expect(read.item).not.toHaveProperty('registration'); + expect(read).toHaveProperty('lock'); + }); + + it('control: no automation service, or one that declares no verdict member — no field at all', async () => { + for (const services of [new Map(), new Map([['automation', { getFlow: async () => null }]])]) { + const { protocol } = harness(VERDICT_ROWS, services as Map); + const read: any = await protocol.getMetaItem({ type: 'flow', name: 'refused_flow' }); + expect(read.item.status).toBe('active'); + expect(read).not.toHaveProperty('registration'); + } + }); + + it('a type the automation engine does not judge reads no field, and the engine is never asked', async () => { + const automation = automationDouble({}); + const { protocol } = harness( + [storedRow('view', 'pkg_view', { name: 'pkg_view', label: 'OVERLAY VIEW' })], + new Map([['automation', automation]]), + ); + + const read: any = await protocol.getMetaItem({ type: 'view', name: 'pkg_view' }); + + expect(read.item.label).toBe('OVERLAY VIEW'); + expect(read).not.toHaveProperty('registration'); + expect(automation.getFlowRegistration).not.toHaveBeenCalled(); + }); + + it('a draft read carries none: the pending body is not what the engine holds', async () => { + const automation = automationDouble({ refused_flow: REFUSED }); + const { protocol } = harness( + [...VERDICT_ROWS, storedRow('flow', 'refused_flow', flowBody('refused_flow', 'DRAFT'), { state: 'draft' })], + new Map([['automation', automation]]), + ); + + const read: any = await protocol.getMetaItem({ type: 'flow', name: 'refused_flow', state: 'draft' }); + + expect(read.item.label).toBe('DRAFT'); + expect(read).not.toHaveProperty('registration'); + }); +}); + +describe('[#23316] the list: `registrations` beside the items, keyed by name', () => { + it('one entry per listed item, the engine\'s answer for each — a loader\'s flow included; the items stay bare', async () => { + const automation = automationDouble({ refused_flow: REFUSED, [CUSTOMER]: { state: 'registered' } }); + const { protocol } = harness(VERDICT_ROWS, new Map([['automation', automation]])); + + const list: any = await protocol.getMetaItems({ type: 'flow' }); + + expect(list.registrations).toEqual({ + refused_flow: REFUSED, + [CUSTOMER]: { state: 'registered' }, + [SHIPPED]: { state: 'unregistered' }, + }); + for (const item of list.items) expect(item).not.toHaveProperty('registration'); + }); + + it('control: no automation service — no `registrations`; and the execution face never carries one', async () => { + const bare: any = await harness(VERDICT_ROWS).protocol.getMetaItems({ type: 'flow' }); + expect(bare.items.length).toBeGreaterThan(0); + expect(bare).not.toHaveProperty('registrations'); + + const automation = automationDouble({ refused_flow: REFUSED }); + const execution: any = await harness(VERDICT_ROWS, new Map([['automation', automation]])) + .protocol.getMetaItemsForExecution({ type: 'flow' }); + expect(execution).not.toHaveProperty('registrations'); + expect(automation.getFlowRegistration).not.toHaveBeenCalled(); + }); + + it('a pending draft overlaid on the list gets no entry', async () => { + const automation = automationDouble({ refused_flow: REFUSED }); + const { protocol } = harness( + [...VERDICT_ROWS, storedRow('flow', 'draft_only', flowBody('draft_only', 'DRAFT'), { state: 'draft' })], + new Map([['automation', automation]]), + ); + + const list: any = await protocol.getMetaItems({ type: 'flow', previewDrafts: true }); + + expect(list.items.map((i: any) => i.name)).toContain('draft_only'); + expect(Object.keys(list.registrations)).not.toContain('draft_only'); + expect(Object.keys(list.registrations)).toContain('refused_flow'); + }); +}); + +describe('[#23316] the cached read — the default door — carries the verdict beside `data`, never in the ETag', () => { + it('serves the engine\'s answer live beside the document, with an ETag over the document alone', async () => { + const automation = automationDouble({ refused_flow: REFUSED }); + const services = new Map([['automation', automation]]); + const judged: any = await harness(VERDICT_ROWS, services).protocol.getMetaItemCached({ type: 'flow', name: 'refused_flow' }); + const unjudged: any = await harness(VERDICT_ROWS).protocol.getMetaItemCached({ type: 'flow', name: 'refused_flow' }); + + expect(judged.registration).toEqual(REFUSED); + expect(judged.data.status).toBe('active'); + expect(judged.data).not.toHaveProperty('registration'); + // The verdict is in no validator: the same document, the same ETag. + expect(judged.etag).toEqual(unjudged.etag); + expect(unjudged).not.toHaveProperty('registration'); + }); + + it('a read that carries the verdict is never answered 304; control: one with no engine still is', async () => { + const services = new Map([['automation', automationDouble({ refused_flow: REFUSED })]]); + const first: any = await harness(VERDICT_ROWS, services).protocol.getMetaItemCached({ type: 'flow', name: 'refused_flow' }); + const revalidate = { type: 'flow', name: 'refused_flow', cacheRequest: { ifNoneMatch: `"${first.etag.value}"` } }; + + const judged: any = await harness(VERDICT_ROWS, services).protocol.getMetaItemCached(revalidate); + expect(judged.notModified).toBe(false); + expect(judged.registration).toEqual(REFUSED); + + const unjudged: any = await harness(VERDICT_ROWS).protocol.getMetaItemCached(revalidate); + expect(unjudged.notModified).toBe(true); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ca7ac9f7dfd..d48ee7e2bd4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { - DataProtocol, MetadataProtocol, PackageProtocol, + DataProtocol, FlowRegistration, MetadataProtocol, PackageProtocol, } from '@objectstack/spec/api'; import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError, relationConditionSites } from '@objectstack/core'; import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types'; @@ -6569,6 +6569,54 @@ export class ObjectStackProtocolImplementation implements return (name, body) => canonicalize.call(automation, name, body); } + /** + * [#23316] The automation engine's verdict reader for a `flow` read — the + * `registration` beside the item read's envelope and the `registrations` + * beside the list's items (`FlowRegistrationSchema` in `@objectstack/spec`). + * + * The same seam as {@link resolveFlowCanonicalizer}: the kernel's service + * table, read by name and per call, for the member the automation service + * declares on `IAutomationService` (`getFlowRegistration`). This package + * never imports the automation service, and resolution stays lazy for the + * reason that docblock gives. + * + * `undefined` — so the read serves NO verdict — for every other type, and + * where no reachable service declares the member: a control-plane or + * metadata-only host judges no flow, and an absent field is how that is + * said. ⛔ Never a third state the protocol makes up: the field is the + * engine's answer or nothing. The answer is the engine of THIS process; no + * cross-process store is read. + */ + private resolveFlowRegistrationReader(type: string): ((name: string) => FlowRegistration) | undefined { + if (canonicalMetaType(type) !== 'flow') return undefined; + const automation = this.getServicesRegistry?.().get('automation') as + | { getFlowRegistration?: (name: string) => FlowRegistration } + | undefined; + const read = automation?.getFlowRegistration; + if (typeof read !== 'function') return undefined; + return (name) => read.call(automation, name); + } + + /** + * [#23316] The list's `registrations`: the engine's verdict per listed + * item, keyed by its `name` — or nothing, where + * {@link resolveFlowRegistrationReader} resolves no reader. A pending draft + * (`_draft: true`, the `previewDrafts` overlay) gets no entry: no engine + * holds a draft, exactly as the lock resolution above skips one. + * `Object.fromEntries`, so a name is an own key whatever it spells. + */ + private flowRegistrationsOf(type: string, items: readonly unknown[]): { registrations?: Record } { + const read = this.resolveFlowRegistrationReader(type); + if (!read) return {}; + const entries: Array<[string, FlowRegistration]> = []; + for (const it of items) { + const name = (it as { name?: unknown } | null | undefined)?.name; + if (typeof name !== 'string' || (it as { _draft?: unknown })._draft === true) continue; + entries.push([name, read(name)]); + } + return { registrations: Object.fromEntries(entries) }; + } + /** * [#20312] The deployment's ADR-0080 SDUI component manifest, when the host * registered one under {@link SDUI_MANIFEST_SERVICE} — `os serve` does, from @@ -10263,6 +10311,10 @@ export class ObjectStackProtocolImplementation implements // [#20552] Served: diagnostics + per-type credential redaction. // Execution: the stored bodies — see `getMetaItemsForExecution`. items: audience === 'served' ? decorateMetadataItems(request.type, governed) : governed, + // [#23316] Served only: the engine's verdict on each listed flow — + // see {@link flowRegistrationsOf}. The execution face is the + // engine's own read, so it carries none. + ...(audience === 'served' ? this.flowRegistrationsOf(request.type, governed) : {}), }; } @@ -11093,6 +11145,13 @@ export class ObjectStackProtocolImplementation implements const lockState = this.servedLockState( request.type, request.name, decorated, artifactBacked, itemLock, storedRowServed, ); + // [#23316] Beside `lock`, never inside `item`: the automation engine's + // verdict on this flow ({@link resolveFlowRegistrationReader}), for an + // item this read serves. The draft returns above carry none — a + // pending body is not what the engine holds. + const readRegistration = decorated !== undefined && decorated !== null + ? this.resolveFlowRegistrationReader(request.type) + : undefined; return { type: request.type, name: request.name, @@ -11117,6 +11176,7 @@ export class ObjectStackProtocolImplementation implements ...(request.previewDrafts ? {} : { version: storedRowServed ? await this.readVersionToken({ ...request, member }, 'active') : null }), + ...(readRegistration ? { registration: readRegistration(request.name) } : {}), }; } @@ -15409,7 +15469,7 @@ export class ObjectStackProtocolImplementation implements * environment → code read order (ADR-0131 D6) — the whole reason this * method delegates rather than re-reading. */ - async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { + async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The ETag // and the cache entry are keyed by type, so two spellings would cache // the same item twice and invalidate only one of them. @@ -15423,6 +15483,11 @@ export class ObjectStackProtocolImplementation implements name: request.name, }); const item = (result as any)?.item; + // [#23316] The automation engine's verdict on a flow, as the + // delegate read above just resolved it — live, on every call. It + // rides beside `data` for the REST layer's default door, and is + // never hashed into the ETag below (see the 304 note there). + const registration = (result as { registration?: FlowRegistration } | undefined)?.registration; if (!item) { // [#5532] Structured: 404 + the catalog's `RESOURCE_NOT_FOUND`. @@ -15476,7 +15541,16 @@ export class ObjectStackProtocolImplementation implements const etag = { value: hash, weak: false }; // Check If-None-Match header - if (request.cacheRequest?.ifNoneMatch) { + // + // [#23316] …except for a read that carries `registration`: the + // served body holds the engine's verdict and this validator does + // not (it hashes the document alone, and the verdict stays out of + // it), so a 304 here would pin the caller to the verdict it was + // served before — after the engine re-registered or refused the + // flow under an unchanged body. Such a read is always answered in + // full; the work is the same either way, since this method always + // performs the delegate read. + if (request.cacheRequest?.ifNoneMatch && registration === undefined) { const clientEtag = request.cacheRequest.ifNoneMatch.replace(/^"(.*)"$/, '$1').replace(/^W\/"(.*)"$/, '$1'); if (clientEtag === hash) { // Return 304 Not Modified @@ -15490,6 +15564,7 @@ export class ObjectStackProtocolImplementation implements // Return full metadata with cache headers return { data: item, + ...(registration !== undefined ? { registration } : {}), etag, lastModified: new Date().toISOString(), cacheControl: { diff --git a/packages/rest/src/meta-flow-cached-registration.test.ts b/packages/rest/src/meta-flow-cached-registration.test.ts new file mode 100644 index 00000000000..021ed8e6be9 --- /dev/null +++ b/packages/rest/src/meta-flow-cached-registration.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #23316 — the DEFAULT `GET /meta/flow/:name` carries the automation engine's +// verdict beside the flow. +// +// `metadata.enableCache` defaults to `true`, so a default deployment answers +// this read from the CACHED branch, which rebuilds the envelope as +// `{ type, name, item }`. The uncached read serves `registration` beside +// `lock`; this branch served the stored row alone, so a flow the engine refused +// at boot read as its stored `status: 'active'` with nothing beside it — the +// defect, at the door every default deployment uses. +// +// The protocol double below answers `getMetaItemCached` the way +// `metadata-protocol`'s producer does: the document under `data`, its ETag, and +// — for a judged flow — the live `registration` its delegate read resolved +// (pinned on the real producer in `protocol.flow-by-name-shipped-name.test.ts`, +// and over the real engine in service-automation's +// `flow-refused-registration-read.integration.test.ts`). + +import { describe, it, expect, vi } from 'vitest'; +import type { FlowRegistration } from '@objectstack/spec/api'; +import { RestServer } from './rest-server'; + +const ANON_API = { api: { requireAuth: false } }; + +const REFUSED: FlowRegistration = { + state: 'refused', + reason: { message: "Flow 'typo' rejected: 1 undeclared config key(s)." }, +}; + +/** The stored flow rows the double serves, with the verdict the engine holds for each. */ +const STORED: Record; registration?: FlowRegistration }> = { + 'flow/typo': { item: { name: 'typo', label: 'Typo', status: 'active' }, registration: REFUSED }, + 'flow/valid': { item: { name: 'valid', label: 'Valid', status: 'active' }, registration: { state: 'registered' } }, + 'page/home': { item: { name: 'home', label: 'Home', type: 'app' } }, +}; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +function setup() { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn(), + findData: vi.fn().mockResolvedValue([]), + getMetaItemCached: vi.fn(async ({ type, name }: { type: string; name: string }) => { + const hit = STORED[`${type}/${name}`]; + return { + data: JSON.parse(JSON.stringify(hit.item)), + ...(hit.registration ? { registration: hit.registration } : {}), + etag: { value: `etag_${name}`, weak: false }, + cacheControl: { directives: ['private', 'no-cache'] }, + notModified: false, + }; + }), + }; + const rest = new RestServer(mockServer() as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return { rest, protocol }; +} + +async function getItem(rest: any, type: string, name: string) { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + if (!route) throw new Error('GET /api/v1/meta/:type/:name route not registered'); + const res = makeRes(); + await route.handler({ method: 'GET', params: { type, name }, query: {}, body: {}, headers: {} }, res); + return res; +} + +describe('the default cached `GET /meta/flow/:name` carries the engine\'s verdict (#23316)', () => { + it('a refused flow: the stored status unchanged, `registration: refused` with its reason beside it', async () => { + const { rest, protocol } = setup(); + + const res = await getItem(rest, 'flow', 'typo'); + + // The default branch, measured — not the uncached read. + expect(protocol.getMetaItemCached).toHaveBeenCalledTimes(1); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body.item).toEqual({ name: 'typo', label: 'Typo', status: 'active' }); + expect(res.body.registration).toEqual(REFUSED); + expect(res.body.item).not.toHaveProperty('registration'); + }); + + it('control: a registered flow\'s cached read is unchanged, beside `registration: registered`', async () => { + const res = await getItem(setup().rest, 'flow', 'valid'); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + type: 'flow', + name: 'valid', + item: { name: 'valid', label: 'Valid', status: 'active' }, + registration: { state: 'registered' }, + }); + }); + + it('control: another type\'s cached envelope is exactly what it was', async () => { + const res = await getItem(setup().rest, 'page', 'home'); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ type: 'page', name: 'home', item: { name: 'home', label: 'Home', type: 'app' } }); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d9e09313499..b59453d43cd 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -6682,6 +6682,20 @@ export class RestServer { // [#9454] The cached door is the `view` arm, and }; const result = await p.getMetaItemCached(cachedRequest); + // [#23316] The automation engine's verdict on a + // FLOW, beside the document and never inside it: + // this branch is the default door of + // `GET /meta/flow/:name`, so without it a flow the + // engine refused was served here as its stored + // `status` alone. The protocol reads it live from + // its delegate read on every call; it is in no + // validator, so a read carrying it is never + // answered 304 (`getMetaItemCached` says why). + // Flow only — every other type's cached envelope + // is unchanged. + const cachedRegistration = metaType === 'flow' && result.registration !== undefined + ? { registration: result.registration } + : {}; if (result.notModified) { res.status(304).send(); @@ -6752,7 +6766,7 @@ export class RestServer { res.header('Vary', 'Accept-Language'); res.json(await this.translateMetaEnvelope( req, req.params.type, environmentId, - { type: metaType, name: req.params.name }, + { type: metaType, name: req.params.name, ...cachedRegistration }, cachedDocument, cacheI18n, runtimeView.document, )); return; @@ -6838,6 +6852,7 @@ export class RestServer { const cachedEnvelope = { type: metaType, name: req.params.name, + ...cachedRegistration, }; res.json(await this.translateMetaEnvelope( req, req.params.type, environmentId, cachedEnvelope, cachedDocument, cacheI18n, diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index a597cd20f6e..532fac6de9e 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -11,6 +11,7 @@ import type { FlowRunSummary, } from '@objectstack/spec/automation'; import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, RunListResult, ScreenSpec, ScreenFieldSpec, ConnectorSourcePullRequest, ConnectorSourcePullResult } from '@objectstack/spec/contracts'; +import type { FlowRegistration, FlowRegistrationRefusal } from '@objectstack/spec/api'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; import { createEvalUser, type EvalUserParsed } from '@objectstack/spec/identity'; import { @@ -2429,6 +2430,21 @@ export class AutomationEngine implements IAutomationService { * {@link getFlowRuntimeStates} can show it. */ private flowShadowing = new Map(); + /** + * [#23316] Refused registrations, keyed by the bare flow name: what this + * engine threw when a host's sync handed it a stored flow it would not + * register, for a name it holds nothing under. The record behind + * {@link getFlowRegistration}'s `refused`. + * + * Present only while the name is unheld: {@link registerFlow} succeeding + * and {@link withdrawFlow} both delete the entry, so a name is never + * `refused` and registered at once. The host writes it + * ({@link recordFlowRefusal}) — the engine cannot tell a sync's refusal + * from a write door's, which answers its caller instead and must leave no + * record. Like {@link flowShadowing} it is a receipt: it never affects + * dispatch. + */ + private flowRefusals = new Map(); /** * [ADR-0126 §7.2] Flows the durable ACTIVATION LEDGER currently marks * inactive — the local projection of `sys_metadata_activation`, keyed by @@ -4500,6 +4516,8 @@ export class AutomationEngine implements IAutomationService { this.flowVersionHistory.set(name, history); this.flows.set(name, parsed); + // [#23316] Held now, so no longer refused — see {@link flowRefusals}. + this.flowRefusals.delete(name); // Enable/disable from the persisted deployment `status`. `obsolete`/`invalid` // flows are DISABLED (unbound + guarded in execute); `draft`/`active` — and // any legacy flow with no explicit status — stay enabled, so existing flows @@ -4627,9 +4645,50 @@ export class AutomationEngine implements IAutomationService { // an in-process bind attempt that no longer has a subject. this.policyDisabledFlows.delete(name); this.subflowDeclinedFlows.delete(name); + // [#23316] A withdrawal ends a refusal as it ends a registration: the + // record's subject is gone. A host that withdraws BECAUSE it refused + // records the refusal after this — see {@link recordFlowRefusal}. + this.flowRefusals.delete(name); this.logger.info(`Flow unregistered: ${name}`); } + /** + * [#23316] Record that a host's sync handed this engine a stored flow and + * the engine refused it, leaving nothing registered under `name` — the + * fact {@link getFlowRegistration} answers as `refused`. + * + * The record keeps what the engine threw, verbatim: the error's own string + * `code` when it set one (absent otherwise — never invented) and its + * `message`. A later {@link registerFlow} success or a + * {@link withdrawFlow} of the name deletes it. + * + * Ignored while the engine holds a registration under `name`: a refused + * re-registration leaves the old one in place (`registerFlow` throws + * before it changes anything), and that flow is registered, not refused. + */ + recordFlowRefusal(name: string, error: unknown): void { + if (this.flows.has(name)) return; + const thrown = (error ?? {}) as { code?: unknown; message?: unknown }; + this.flowRefusals.set(name, { + ...(typeof thrown.code === 'string' ? { code: thrown.code } : {}), + message: typeof thrown.message === 'string' ? thrown.message : String(error), + }); + } + + /** + * [#23316] This engine's verdict on one flow name, for the metadata read + * that serves it beside the stored flow — see + * `IAutomationService.getFlowRegistration`. Total over this engine's state: + * `registered` while {@link flows} holds the name, `refused` with the + * recorded reason while {@link flowRefusals} does, `unregistered` + * otherwise. A read: it records nothing. + */ + getFlowRegistration(name: string): FlowRegistration { + if (this.flows.has(name)) return { state: 'registered' }; + const refusal = this.flowRefusals.get(name); + return refusal ? { state: 'refused', reason: { ...refusal } } : { state: 'unregistered' }; + } + /** * Runtime enable/bound state for every registered flow — the truth behind the * Studio's status badges. The persisted `status` is metadata; whether a flow diff --git a/packages/services/service-automation/src/flow-refused-registration-read.integration.test.ts b/packages/services/service-automation/src/flow-refused-registration-read.integration.test.ts new file mode 100644 index 00000000000..aedb0326b17 --- /dev/null +++ b/packages/services/service-automation/src/flow-refused-registration-read.integration.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23316 — a stored flow the engine refuses at boot is not served as running, + * and its reason is readable; a registered flow reads as before. + * + * The defect: the `kernel:ready` cold-boot bind refused a stored flow, warned, + * and withdrew it (#21848), while the metadata read kept serving the stored row + * as `status: 'active'` with nothing beside it. After the protocol-18 upgrade + * every stored flow carrying a shape the engine now refuses boots into exactly + * that state. + * + * The real composition, as `GET /api/v1/meta/flow/:name` and the list read + * reach it: an ObjectKernel, ObjectQLPlugin's metadata protocol over + * driver-sql on better-sqlite3 `:memory:`, and the real automation plugin. + * The stored rows land in `sys_metadata` after ObjectQL has started, so the + * boot pull never sees them and the `kernel:ready` bind is the step that + * judges them; the node type's executor is registered from a later plugin's + * `start()` (the #21848 harness's shape), so the bind refuses the row whose + * plugin node carries a key the descriptor does not declare — a row stored + * under one vocabulary and refused under the next, as an upgrade does. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import type { GetMetaItemCachedResponse, GetMetaItemResponse, GetMetaItemsResponse } from '@objectstack/spec/api'; +import { AutomationServicePlugin } from './plugin.js'; +import type { AutomationEngine, NodeExecutor } from './engine.js'; + +/** The read surface these tests drive: `ObjectStackProtocolImplementation`'s meta doors. */ +interface MetadataReadDoors { + getMetaItem(request: { type: string; name: string }): Promise; + /** The default door's producer: REST's cached branch serves `data` and `registration` from it. */ + getMetaItemCached(request: { type: string; name: string }): Promise; + getMetaItems(request: { type: string }): Promise; + saveMetaItem(request: { type: string; name: string; item: unknown }): Promise; +} + +const SYS = { isSystem: true } as const; + +/** A plugin node type the spec knows nothing about: its descriptor declares `count`. */ +const STAMP = 'test_stamp'; + +function stampExecutor(): NodeExecutor { + return { + type: STAMP, + descriptor: defineActionDescriptor({ + type: STAMP, + version: '1.0.0', + name: 'Stamp', + category: 'custom', + paradigms: ['flow'], + source: 'plugin', + configSchema: { type: 'object', properties: { count: { type: 'number' } } }, + }), + async execute() { + return { success: true }; + }, + }; +} + +/** An active, manually-launched flow whose one plugin node carries `config`. */ +function stampFlow(name: string, config: Record) { + return { + name, + label: name, + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'stamp', type: STAMP, label: 'Stamp', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'stamp' }, + { id: 'e2', source: 'stamp', target: 'end' }, + ], + }; +} + +/** + * The deployment's store and the late node type: the driver joins in `init()` + * (ObjectQL connects it and creates `sys_metadata` in its own `start()`); the + * rows land and the executor registers in this plugin's `start()`, after the + * boot pull and before `kernel:ready`. + */ +function storedFlowsPlugin(driver: SqlDriver, flows: ReadonlyArray>): Plugin { + return { + name: 'test.stored-flows', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.getService('objectql').registerDriver(driver as never, true); + }, + async start(ctx: PluginContext) { + const ql = ctx.getService('objectql'); + for (const flow of flows) { + await ql.insert( + 'sys_metadata', + { id: `meta_${flow.name}`, name: flow.name, type: 'flow', state: 'active', metadata: JSON.stringify(flow) }, + { context: SYS } as never, + ); + } + ctx.getService('automation').registerNodeExecutor(stampExecutor()); + }, + }; +} + +describe('a stored flow the engine refuses at boot is not served as running (#23316)', () => { + let kernel: ObjectKernel; + + afterEach(async () => { + if (kernel?.getState() === 'running') await kernel.shutdown(); + }); + + async function boot() { + kernel = new ObjectKernel({ logger: { level: 'fatal' }, gracefulShutdown: false } as never); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + // `cuont` is a key the node type's descriptor does not declare. + await kernel.use(storedFlowsPlugin(driver, [stampFlow('typo', { cuont: 2 }), stampFlow('valid', { count: 2 })])); + await kernel.bootstrap(); + return { + protocol: kernel.getService('protocol'), + automation: kernel.getService('automation'), + }; + } + + /** What the engine throws for this body — the reason the read must carry verbatim. */ + function refusalOf(automation: AutomationEngine, body: unknown): Error & { code?: unknown } { + try { + automation.registerFlow('typo', body); + } catch (e) { + return e as Error & { code?: unknown }; + } + throw new Error('control: the engine was expected to refuse this body'); + } + + it('the item read serves the stored status unchanged, beside the engine\'s refusal and its reason', async () => { + const { protocol, automation } = await boot(); + expect(await automation.getFlow('typo'), 'control: the bind refused the stored row').toBeNull(); + + const read = await protocol.getMetaItem({ type: 'flow', name: 'typo' }); + // Two facts, apart: the author's declared state, as stored… + expect((read.item as { status?: string }).status).toBe('active'); + // …and the engine's verdict, beside it — what it threw, verbatim. + const thrown = refusalOf(automation, stampFlow('typo', { cuont: 2 })); + expect(read.registration).toEqual({ state: 'refused', reason: { message: thrown.message } }); + expect(thrown.code, 'this refusal is a plain error, so the reason carries no code').toBeUndefined(); + expect(read.registration?.state === 'refused' && read.registration.reason.message).toContain('cuont'); + }); + + it('the cached read — what the default `GET /meta/flow/:name` serves from — carries the same refusal', async () => { + const { protocol, automation } = await boot(); + + const cached = await protocol.getMetaItemCached({ type: 'flow', name: 'typo' }); + const thrown = refusalOf(automation, stampFlow('typo', { cuont: 2 })); + expect((cached.data as { status?: string }).status).toBe('active'); + expect(cached.registration).toEqual({ state: 'refused', reason: { message: thrown.message } }); + expect((await protocol.getMetaItemCached({ type: 'flow', name: 'valid' })).registration).toEqual({ state: 'registered' }); + }); + + it('control: a registered stored flow reads `registered`, its status as stored', async () => { + const { protocol, automation } = await boot(); + expect(await automation.getFlow('valid')).not.toBeNull(); + + const read = await protocol.getMetaItem({ type: 'flow', name: 'valid' }); + expect((read.item as { status?: string }).status).toBe('active'); + expect(read.registration).toEqual({ state: 'registered' }); + }); + + it('the list carries each verdict beside the items, keyed by name — no per-item read', async () => { + const { protocol } = await boot(); + const list = await protocol.getMetaItems({ type: 'flow' }); + + expect(list.registrations?.typo).toMatchObject({ state: 'refused' }); + expect(list.registrations?.valid).toEqual({ state: 'registered' }); + const typo = (list.items as Array<{ name?: string; status?: string }>).find((i) => i.name === 'typo'); + expect(typo?.status, 'the listed item stays as authored').toBe('active'); + expect(typo).not.toHaveProperty('registration'); + }); + + it('a later successful registration of the name ends the refusal', async () => { + const { protocol, automation } = await boot(); + await protocol.saveMetaItem({ type: 'flow', name: 'typo', item: stampFlow('typo', { count: 3 }) }); + + await vi.waitFor(async () => { + expect(await automation.getFlow('typo'), 'the mutation sync registered the corrected row').not.toBeNull(); + }); + expect((await protocol.getMetaItem({ type: 'flow', name: 'typo' })).registration).toEqual({ state: 'registered' }); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 409519569ba..29d687232ea 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -2522,6 +2522,9 @@ export class AutomationServicePlugin implements Plugin { flow: entry.name, ...describeThrownForLog(err), }); + // [#23316] A flow this refusal leaves unregistered is readable as + // refused; one the engine still holds keeps that registration. + this.engine.recordFlowRefusal(entry.name, err); } } @@ -2568,6 +2571,14 @@ export class AutomationServicePlugin implements Plugin { * refusal's warning kept the flow `active` and bound to its trigger. * Only the refused name is withdrawn; a failed or empty READ still tears * nothing down (the early returns above). + * + * [#23316] The refusal is then RECORDED on the engine + * (`recordFlowRefusal`), so the metadata read serves the stored flow + * beside `registration: { state: 'refused', reason }` instead of only its + * stored `status: 'active'`. This bind is the boot's verdict: the boot + * pull's own refusals are not recorded, because this bind re-judges every + * flow in the view at `kernel:ready` — a record made there could be + * contradicted by this same boot (AGENTS.md "Startup registry reads"). */ private async syncFlowsFromProtocol(ctx: PluginContext): Promise { if (!this.engine) return; @@ -2594,6 +2605,10 @@ export class AutomationServicePlugin implements Plugin { this.engine.withdrawFlow(entry.name); this.syncedFlowNames.delete(entry.name); } + // [#23316] …and the refusal is readable: the engine holds + // nothing under the name now, so the metadata read serves the + // stored flow beside `registration: refused` and this error. + this.engine.recordFlowRefusal(entry.name, err); } } if (bound > 0) { @@ -2732,6 +2747,8 @@ export class AutomationServicePlugin implements Plugin { + 'registration it had, if any', { flow: name, ...describeThrownForLog(err) }, ); + // [#23316] With none to keep, the stored flow is readable as refused. + engine.recordFlowRefusal(name, err); } } if (armed.length > 0 || unregistered.length > 0) { diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index acaa363c68d..f464b19a2aa 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -402,6 +402,10 @@ "FindReferencesToMetaResponse (type)", "FindReferencesToMetaResponseParsed (type)", "FindReferencesToMetaResponseSchema (const)", + "FlowRegistration (type)", + "FlowRegistrationRefusal (type)", + "FlowRegistrationRefusalSchema (const)", + "FlowRegistrationSchema (const)", "GeneratedApiDocumentation (type)", "GeneratedApiDocumentationParsed (type)", "GeneratedApiDocumentationSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index aad9e323e64..884e04b86d1 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -688,6 +688,8 @@ "api/FindDataResponse:records", "api/FindDataResponse:total", "api/FindReferencesToMetaResponse:references", + "api/FlowRegistrationRefusal:code", + "api/FlowRegistrationRefusal:message", "api/GeneratedApiDocumentation:generatedAt", "api/GeneratedApiDocumentation:html", "api/GeneratedApiDocumentation:markdown", @@ -757,6 +759,7 @@ "api/GetMetaItemCachedResponse:etag", "api/GetMetaItemCachedResponse:lastModified", "api/GetMetaItemCachedResponse:notModified", + "api/GetMetaItemCachedResponse:registration", "api/GetMetaItemCachedResponse:version", "api/GetMetaItemLayeredRequest:name", "api/GetMetaItemLayeredRequest:packageId", @@ -795,6 +798,7 @@ "api/GetMetaItemResponse:packageId", "api/GetMetaItemResponse:packageVersion", "api/GetMetaItemResponse:provenance", + "api/GetMetaItemResponse:registration", "api/GetMetaItemResponse:resettable", "api/GetMetaItemResponse:sortability", "api/GetMetaItemResponse:type", @@ -803,6 +807,7 @@ "api/GetMetaItemsRequest:previewDrafts", "api/GetMetaItemsRequest:type", "api/GetMetaItemsResponse:items", + "api/GetMetaItemsResponse:registrations", "api/GetMetaItemsResponse:type", "api/GetMetaTypesResponse:entries", "api/GetMetaTypesResponse:types", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 068260d6f13..aa83acb2a39 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -289,6 +289,10 @@ "FindDataResponseSchema": "api/FindDataResponse", "FindReferencesToMetaResponse": "api/FindReferencesToMetaResponse", "FindReferencesToMetaResponseSchema": "api/FindReferencesToMetaResponse", + "FlowRegistration": "api/FlowRegistration", + "FlowRegistrationRefusal": "api/FlowRegistrationRefusal", + "FlowRegistrationRefusalSchema": "api/FlowRegistrationRefusal", + "FlowRegistrationSchema": "api/FlowRegistration", "GeneratedApiDocumentation": "api/GeneratedApiDocumentation", "GeneratedApiDocumentationSchema": "api/GeneratedApiDocumentation", "GeneratedEndpoint": "api/GeneratedEndpoint", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 3f06b7fee54..e4b2e9433a4 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -381,6 +381,10 @@ "FindReferencesToMetaResponse": "src/api/protocol.zod.ts#FindReferencesToMetaResponse (type)", "FindReferencesToMetaResponseParsed": "src/api/protocol.zod.ts#FindReferencesToMetaResponseParsed (type)", "FindReferencesToMetaResponseSchema": "src/api/protocol.zod.ts#FindReferencesToMetaResponseSchema (const)", + "FlowRegistration": "src/api/protocol.zod.ts#FlowRegistration (type)", + "FlowRegistrationRefusal": "src/api/protocol.zod.ts#FlowRegistrationRefusal (type)", + "FlowRegistrationRefusalSchema": "src/api/protocol.zod.ts#FlowRegistrationRefusalSchema (const)", + "FlowRegistrationSchema": "src/api/protocol.zod.ts#FlowRegistrationSchema (const)", "GeneratedApiDocumentation": "src/api/documentation.zod.ts#GeneratedApiDocumentation (type)", "GeneratedApiDocumentationParsed": "src/api/documentation.zod.ts#GeneratedApiDocumentationParsed (type)", "GeneratedApiDocumentationSchema": "src/api/documentation.zod.ts#GeneratedApiDocumentationSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index d5ffaa15fd6..e2e055f68ee 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -159,6 +159,8 @@ "api/FindDataRequest", "api/FindDataResponse", "api/FindReferencesToMetaResponse", + "api/FlowRegistration", + "api/FlowRegistrationRefusal", "api/GeneratedApiDocumentation", "api/GeneratedEndpoint", "api/GetAnalyticsMetaRequest", diff --git a/packages/spec/src/api/meta-item-response-shapes.test.ts b/packages/spec/src/api/meta-item-response-shapes.test.ts index 3280179b1af..de536dae48f 100644 --- a/packages/spec/src/api/meta-item-response-shapes.test.ts +++ b/packages/spec/src/api/meta-item-response-shapes.test.ts @@ -21,11 +21,19 @@ import { describe, it, expect } from 'vitest'; import { + FlowRegistrationSchema, GetMetaItemResponseSchema, + GetMetaItemsResponseSchema, GetMetaItemLayeredResponseSchema, MetadataConflictErrorSchema, } from './protocol.zod'; -import type { GetMetaItemResponse, GetMetaItemLayeredResponse, MetadataConflictError } from './protocol.zod'; +import type { + FlowRegistration, + GetMetaItemResponse, + GetMetaItemsResponse, + GetMetaItemLayeredResponse, + MetadataConflictError, +} from './protocol.zod'; import type { MetadataLock } from '../kernel/metadata-protection.zod'; /** Type-level identity / assignability helpers. */ @@ -254,3 +262,46 @@ describe('the read serves the version token; the conflict carries the current on expect(MetadataConflictErrorSchema.safeParse({ ...body, currentVersion: null, code: 'CONCURRENT_UPDATE' }).success).toBe(false); }); }); + +/** + * #23316 — the automation engine's verdict on a stored flow is reachable and + * typed on both reads, as a closed set: what an SDK caller branches on without + * a cast, and the `refused` reason a reader renders. + */ +export type RegistrationIsTheVerdictOrAbsent = Assert< Eq< GetMetaItemResponse['registration'], FlowRegistration | undefined > >; +export type ListRegistrationsAreKeyedVerdicts = Assert< Eq< GetMetaItemsResponse['registrations'], Record | undefined > >; + +describe('the engine\'s verdict on a stored flow: `registration` beside the item, `registrations` beside the list', () => { + const FLOW = { name: 'nightly_sync', status: 'active' }; + const REFUSED = { state: 'refused', reason: { message: "Flow 'nightly_sync' rejected: 1 undeclared config key(s)." } }; + + it('keeps each verdict through a parse — the `refused` reason with or without the engine\'s code', () => { + for (const registration of [ + { state: 'registered' }, + { state: 'unregistered' }, + REFUSED, + { state: 'refused', reason: { code: 'VALIDATION_ERROR', message: 'invalid flow' } }, + ]) { + expect(GetMetaItemResponseSchema.parse({ type: 'flow', name: 'nightly_sync', item: FLOW, registration }).registration) + .toEqual(registration); + } + // Absent stays absent: no engine judged it, which is never `registered`. + expect('registration' in GetMetaItemResponseSchema.parse({ type: 'flow', name: 'nightly_sync', item: FLOW })).toBe(false); + }); + + it('is a closed set: no fourth state, and `refused` REQUIRES the reason', () => { + expect(FlowRegistrationSchema.safeParse({ state: 'running' }).success).toBe(false); + expect(FlowRegistrationSchema.safeParse({ state: 'refused' }).success).toBe(false); + expect(FlowRegistrationSchema.safeParse({ state: 'refused', reason: { code: 'X' } }).success).toBe(false); + }); + + it('the list keeps `registrations` keyed by name through a parse', () => { + const parsed = GetMetaItemsResponseSchema.parse({ + type: 'flow', + items: [FLOW], + registrations: { nightly_sync: REFUSED }, + }); + expect(parsed.registrations).toEqual({ nightly_sync: REFUSED }); + expect('registrations' in GetMetaItemsResponseSchema.parse({ type: 'flow', items: [FLOW] })).toBe(false); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index ef0dc2a2b94..af9b56ceab9 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -249,12 +249,84 @@ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ ), })); +/** + * What the automation engine threw when it refused to register a stored flow: + * the engine's own `code` and `message`, verbatim. ⛔ Never paraphrased, never + * re-coded: a consumer renders `message`, and branches on `code` only where + * the engine set one (many registration refusals are plain errors, which carry + * none — so `code` is absent there rather than invented). + */ +export const FlowRegistrationRefusalSchema = lazySchema(() => z.object({ + code: z.string().optional().describe( + 'The machine code the engine\'s refusal carried, verbatim. Absent when the thrown error carried none.', + ), + message: z.string().describe( + 'The engine\'s refusal message, verbatim. Render it as sent; do not parse it.', + ), +})); + +/** + * The automation engine's verdict on ONE stored flow, as the metadata read + * serves it — beside the item, never inside it. + * + * Two facts, deliberately apart: the stored `status` is what the author + * declared and stays as authored, while this is what the engine made of the + * definition. Before this member existed, a flow the engine refused at boot + * (withdrawn, with a warn line) was served as `status: 'active'` and nothing + * on the read said it never runs. + * + * A closed set, total over the engine's state for a name: + * + * - `registered` — the engine holds a registration under the name. + * Registration is not arming: whether a trigger is bound is + * `GET /automation/_status`'s answer, not this one. + * - `refused` — the engine's last attempt to register the stored flow threw, + * and it holds nothing under the name; `reason` is what it threw. A later + * successful registration of the name (a save, a publish, a reload) ends + * the state. + * - `unregistered` — the engine holds nothing under the name and recorded no + * refusal: the flow has not reached it (a read during boot, or between a + * save and the engine following it), or it was unregistered on purpose. + * + * It is the verdict of the engine in the process that answered the read: on a + * deployment of several processes each answers for its own engine, and no + * cross-process store is consulted. + */ +export const FlowRegistrationSchema = lazySchema(() => z.discriminatedUnion('state', [ + z.object({ + state: z.literal('registered').describe('The engine holds a registration under the flow\'s name.'), + }), + z.object({ + state: z.literal('refused').describe( + 'The engine refused to register the stored flow and holds nothing under its name: it never runs.', + ), + reason: FlowRegistrationRefusalSchema.describe('What the engine threw, verbatim.'), + }), + z.object({ + state: z.literal('unregistered').describe( + 'The engine holds nothing under the flow\'s name and recorded no refusal for it.', + ), + }), +])); + /** * Get Metadata Items Response + * + * `registrations` is the list's position for the automation engine's verdict + * ({@link FlowRegistrationSchema}): the items stay bare documents, so the + * verdict sits beside them, keyed by each item's `name` — readable without a + * per-item read, and never inside an item. */ export const GetMetaItemsResponseSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), items: z.array(z.unknown()).describe('Array of metadata items'), + registrations: z.record(z.string(), FlowRegistrationSchema).optional().describe( + 'The automation engine\'s verdict on each listed flow, keyed by the item\'s `name`: one entry per ' + + 'listed item that is not a pending draft. Present only on a `flow` list answered by a process that ' + + 'runs an automation service; absent otherwise, and absence means no engine judged them, never ' + + '`registered`. The verdict is the answering process\'s engine. Beside the items, never inside one: ' + + 'an item\'s `status` stays what its author declared.', + ), })); /** @@ -430,6 +502,17 @@ const MetadataProtectionEnvelopeFields = { * Like the protection keys it is published by the uncached branch only (the * cached branch never consults the store's rows), and it is the one key whose * `null` is a statement: no stored row is there, so the next save is a create. + * + * [#23316] `registration` is the automation engine's verdict on a `flow` + * ({@link FlowRegistrationSchema}), an envelope sibling of `lock` for the same + * reason `lock` is one: it is a fact about the item that the document cannot + * carry. The stored `status` is the author's declared state, and a flow the + * engine refused at boot used to be served as `status: 'active'` with nothing + * beside it. Unlike the protection keys it is published by BOTH branches: + * the cached branch is the default door, so it carries the verdict too, read + * live and kept out of the ETag ({@link GetMetaItemCachedResponseSchema}). It + * is absent wherever no automation engine answered, so absence is never read + * as `registered`. */ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), @@ -460,6 +543,16 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ + 'published here", never "no row". Opaque — echo it verbatim, never parse ' + 'it.', ), + registration: FlowRegistrationSchema.optional().describe( + 'The automation engine\'s verdict on this flow (see `FlowRegistrationSchema`): `registered`, or ' + + '`refused` with the engine\'s `reason` verbatim, or `unregistered`. Present only on a `flow` read ' + + 'answered by a process that runs an automation service; absent otherwise, and absence means no ' + + 'engine judged it, never `registered`. Unlike the protection keys it is published by the cached ' + + 'branch too, read live and never part of the ETag, so a cached read that carries it is never ' + + 'answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is ' + + 'the answering process\'s engine. Beside `item`, never inside it: the item\'s `status` stays what ' + + 'its author declared.', + ), })); /** @@ -1929,8 +2022,22 @@ export const GetMetaItemCachedRequestSchema = lazySchema(() => z.object({ /** * Get Metadata Item with Cache Response * Uses MetadataCacheResponse from http-cache.zod.ts + * + * [#23316] …plus `registration`: the cached read is the DEFAULT door of + * `GET /meta/flow/:name`, so the automation engine's verdict on a stored flow + * rides it too, read live from the delegate read on every call. It is never + * folded into `etag`, so a read that carries it is never answered + * `notModified`: a 304 would pin the caller to the verdict it was served + * before, which the validator cannot see change. */ -export const GetMetaItemCachedResponseSchema = lazySchema(() => MetadataCacheResponseSchema); +export const GetMetaItemCachedResponseSchema = lazySchema(() => MetadataCacheResponseSchema.extend({ + registration: FlowRegistrationSchema.optional().describe( + 'The automation engine\'s verdict on the flow in `data`, read live on every call and never part of ' + + '`etag` (see `FlowRegistrationSchema`). Present only for a `flow` read answered by a process that ' + + 'runs an automation service; a read that carries it is never answered `notModified`. Absent ' + + 'otherwise, and absence means no engine judged it, never `registered`.', + ), +})); /** * Get UI View Request @@ -3402,6 +3509,8 @@ export type GetDiscoveryResponse = z.input; export type GetMetaTypesRequest = z.input; export type GetMetaTypesResponse = z.input; export type GetMetaItemsRequest = z.input; +export type FlowRegistrationRefusal = z.input; +export type FlowRegistration = z.input; export type GetMetaItemsResponse = z.input; export type GetMetaItemRequest = z.input; export type GetMetaItemResponse = z.input; diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index b42795b3ba0..8983f689aa0 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -19,6 +19,7 @@ import type { ActionDescriptor } from '../automation/node-executor.zod'; import type { ConnectorDescriptor } from '../integration/connector-descriptor'; import type { ConversionNotice, ConversionConflictNotice, ConversionTodoNotice } from '../conversions/types'; import type { ExecutionContext } from '../kernel/execution-context.zod'; +import type { FlowRegistration } from '../api/protocol.zod'; /** * Context passed to a flow/script execution @@ -1000,6 +1001,31 @@ export interface IAutomationService { */ getFlowRuntimeStates?(): FlowRuntimeState[]; + /** + * This engine's verdict on ONE flow name — whether it holds a registration + * under it, refused the stored flow, or holds nothing (`FlowRegistration`, + * `api/protocol.zod.ts`). + * + * [#23316] Declared for the metadata read, which serves it beside a stored + * flow (`registration` on the item read, `registrations` on the list) so a + * reader can tell a flow the engine refused from one it runs: the stored + * `status` is the author's declared state, and a flow refused at boot was + * served as `active` while it never fired. The protocol reads this member + * through the kernel's service table by name, as it reads + * {@link canonicalizeStoredFlow} — the metadata protocol never imports the + * automation service. + * + * {@link getFlowRuntimeStates} cannot answer it: it enumerates the flows + * the engine HOLDS, and a refused flow is exactly the one it does not. + * + * Optional: an automation implementation that keeps no refusal record does + * not declare it, and the read then serves no verdict at all rather than a + * guess. + * + * @param name - Flow name (snake_case) + */ + getFlowRegistration?(name: string): FlowRegistration; + /** * Resume a run that suspended at a pausing node (ADR-0019). The run must * have previously returned `{ status: 'paused', runId }` from diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index db32c9d8292..bfb56107b02 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M188 from './ai/build-progress.zod.js'; import type * as M189 from './system/fate-supplement.zod.js'; // --------------------------------------------------------------------------- -// 783 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 785 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 @@ -568,6 +568,8 @@ export type Iso_api_protocol__DeleteDataResponseSchema = Assert, z.infer< typeof M28.DeleteMetaItemRequestSchema > >>; export type Iso_api_protocol__DeleteMetaItemResponseSchema = Assert, z.infer< typeof M28.DeleteMetaItemResponseSchema > >>; export type Iso_api_protocol__FindDataResponseSchema = Assert, z.infer< typeof M28.FindDataResponseSchema > >>; +export type Iso_api_protocol__FlowRegistrationRefusalSchema = Assert, z.infer< typeof M28.FlowRegistrationRefusalSchema > >>; +export type Iso_api_protocol__FlowRegistrationSchema = Assert, z.infer< typeof M28.FlowRegistrationSchema > >>; export type Iso_api_protocol__GetDataRequestSchema = Assert, z.infer< typeof M28.GetDataRequestSchema > >>; export type Iso_api_protocol__GetDataResponseSchema = Assert, z.infer< typeof M28.GetDataResponseSchema > >>; export type Iso_api_protocol__GetDiscoveryRequestSchema = Assert, z.infer< typeof M28.GetDiscoveryRequestSchema > >>; @@ -1689,7 +1691,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 783 isomorphic pins', () => { + it('still declares all 785 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 @@ -2501,7 +2503,16 @@ describe('ADR-0122 type-alias convention', () => { // closed objects, unions and enums with no default and no transform in // their trees, so each is pinned here (Iso_system_fateSupplement__*) // rather than given an `XParsed` synonym. +7 added. - expect(pins).toHaveLength(783); + // + // 783 -> 785 is #23316: the flow meta read's engine verdict, the new + // exported `FlowRegistrationSchema` (a discriminated union of three closed + // literal arms) and `FlowRegistrationRefusalSchema` (two plain strings, one + // optional) — no default and no transform anywhere in either tree, so both + // are pinned here (Iso_api_protocol__FlowRegistrationSchema, + // Iso_api_protocol__FlowRegistrationRefusalSchema) rather than given + // `…Parsed` synonyms. +2 added (landed beside the fate supplement's +7; + // both counted from the same base of 776). + expect(pins).toHaveLength(785); // 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/variant-docs.json b/packages/spec/variant-docs.json index a0d2e34b2ff..a4e2a7060ea 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -170,6 +170,14 @@ "label": "host fate supplement attribution anchor", "exempt": "generated-reference-only", "reason": "The fate-3 anchor inside a host fate supplement row; see the `fate:` entry for the same command, the same file and the same documentation gap." + }, + { + "key": "state:refused|registered|unregistered", + "label": "flow registration verdict", + "docs": [ + "content/docs/automation/flows.mdx" + ], + "note": "The automation engine's verdict the flow meta read serves beside a stored flow (#23316). Not authored — the engine answers it — but GOVERNED rather than exempt: a reader acts on each state differently, and automation/flows.mdx ('Discovery & Registration') names all three in its state table." } ] }