diff --git a/.changeset/workflow-slot-retired.md b/.changeset/workflow-slot-retired.md
new file mode 100644
index 0000000000..cc5b590639
--- /dev/null
+++ b/.changeset/workflow-slot-retired.md
@@ -0,0 +1,58 @@
+---
+"@objectstack/spec": major
+"@objectstack/client": major
+"@objectstack/metadata-protocol": minor
+"@objectstack/runtime": minor
+---
+
+refactor(spec,client,metadata-protocol,runtime)!: retire the workflow service slot — declared end to end, implemented nowhere (#4451)
+
+The `workflow` slot was ADR-0078's silently-inert declaration at every layer at
+once: a `CoreServiceName` nothing ever registered or resolved (ADR-0115
+Evidence 5 — "no code in this repository resolves either slot", verified across
+both repositories), an `IWorkflowService` contract with zero implementations, a
+`WorkflowProtocol` whose three methods no code ever provided, a discovery
+`routes.workflow` field no builder could truthfully populate, and a
+`/api/v1/workflow` advertisement for a path no host ever mounted (the
+pre-#3586 `DEFAULT_DISPATCHER_ROUTES` already listed it among routes that
+never existed). The capability it promised is live elsewhere and has been for
+majors: record state machines are enforced by the `state_machine` validation
+rule, approvals are first-class flow nodes on the approvals runtime
+(ADR-0019), and record-triggered automation is lifecycle hooks +
+`record_change` flows (`service-automation`).
+
+FROM → TO:
+
+- `CoreServiceName 'workflow'` / `ServiceRequirementDef.workflow` /
+ `CORE_SERVICE_PROVIDER['workflow']` → removed; there is no slot to fill.
+- `IWorkflowService` (`@objectstack/spec/contracts`) → removed; no
+ implementation ever existed. Register nothing — use the mechanisms above.
+- `WorkflowProtocol` + `GetWorkflowConfigRequest/Response`,
+ `WorkflowState`, `GetWorkflowStateRequest/Response`,
+ `WorkflowTransitionRequest/Response` (`@objectstack/spec/api`) → removed,
+ along with the seven published JSON schemas. Delete the import; nothing
+ ever answered these shapes.
+- Discovery `routes.workflow` / `services.workflow` / `features.workflow`
+ (metadata-protocol + runtime builders) → absent. A reader keying on them
+ only ever saw `unavailable` / `false`; delete the read.
+- `RouterConfig.mounts.workflow` → removed; there was never a surface to
+ mount at it.
+- `RestApiRouteCategory 'workflow'` → removed; categorize automation-adjacent
+ routes as `'automation'`.
+- `@objectstack/client` re-exports of the four workflow types → removed with
+ their source. (The `client.workflow.*` methods were already removed earlier
+ in the v17 cycle — this retires the types they returned.)
+- Also removed: the stray `graphql` entry in `CORE_SERVICE_PROVIDER` and the
+ `graphql: { route: '/graphql' }` discovery entry — `graphql` was never a
+ `CoreServiceName`, and the dispatcher had already dropped `/graphql` as out
+ of the product plan (#2462 follow-on).
+
+The retirement kit: the `workflow-service-slot-retired` semantic migration
+(major 17) carries this prescription into `spec-changes.json`, the generated
+upgrade guide and the `spec_changes` MCP tool. These are TS/API surfaces and a
+discovery response field — never stored in stack metadata — so there is no
+load-path conversion and nothing for `os migrate meta` to rewrite; the
+21 `authorable-surface.json` baseline lines and 7 `json-schema.manifest.json`
+entries for the deleted schemas are dropped deliberately in the same change
+(the plugin-runtime precedent: a prescription nobody can receive is noise —
+nothing parses these shapes any more).
diff --git a/content/docs/api/plugin-endpoints.mdx b/content/docs/api/plugin-endpoints.mdx
index 4463d58791..d48504a35a 100644
--- a/content/docs/api/plugin-endpoints.mdx
+++ b/content/docs/api/plugin-endpoints.mdx
@@ -26,19 +26,21 @@ Authenticate with email and password (better-auth's email sign-in route, mounted
The following endpoints become available when the corresponding plugin is installed and registered with the kernel. Use the discovery `services` map to check availability.
-### Workflow (`/workflow`) — Plugin Required
+### Workflow (`/workflow`) — removed in v17
-Not yet mounted. These routes are declared in the API protocol but the core dispatcher registers no `/workflow` handler and no bundled plugin provides a `workflow` service (only an in-memory dev stub), so they return **404** today. `discovery.services.workflow` reports `unavailable` in a standard install.
+There is no workflow endpoint, and there is no `workflow` service slot. The
+three routes documented here were declared in the API protocol and served by
+nothing — no dispatcher handler, no plugin — so they 404'd for the whole life
+of the declaration. The slot, the `WorkflowProtocol` methods behind it and the
+discovery fields that reported it were all retired in v17 ([#4451](https://github.com/objectstack-ai/objectstack/issues/4451)).
+Use the live mechanisms instead: an object validation rule of type
+`state_machine` for lifecycle transitions, an `approval` flow node for human
+approval pauses (ADR-0019), and lifecycle hooks / `record_change` flows for
+record-triggered automation.
-| Method | Endpoint | Description |
-|:-------|:---------|:------------|
-| GET | `/workflow/:object/config` | Get workflow configuration |
-| GET | `/workflow/:object/:recordId/state` | Get record's workflow state |
-| POST | `/workflow/:object/:recordId/transition` | Execute state transition |
-
-Approve/reject are **not** workflow routes (ADR-0019): approval is a flow node, and decisions are recorded on the approvals runtime via `POST /approvals/requests/:id/approve` and `POST /approvals/requests/:id/reject`.
+Approve/reject were never workflow routes (ADR-0019): approval is a flow node, and decisions are recorded on the approvals runtime via `POST /approvals/requests/:id/approve` and `POST /approvals/requests/:id/reject`.
### Automation (`/automation`) — Plugin Required
diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx
index 67f18463a2..763f88e664 100644
--- a/content/docs/kernel/services-checklist.mdx
+++ b/content/docs/kernel/services-checklist.mdx
@@ -24,12 +24,14 @@ The ObjectStack protocol defines **16 kernel services** registered via the `Core
- ✅ Implemented — the 17 kernel-provided protocol methods (`DataProtocol` 9 + `MetadataProtocol` 8)
- ⚠️ Framework — the slot is filled by the kernel's in-memory fallback: real reads and
writes, no persistence (self-declares `degraded`, ADR-0076 D12)
-- ❌ Plugin Required — the remaining 36 methods declared across the other per-domain
+- ❌ Plugin Required — the remaining 33 methods declared across the other per-domain
contracts (`analytics` 2, `automation` 1, `packages` 6, `views` 5, `permissions` 3,
- `workflow` 3, `realtime` 6, `notification` 7, `i18n` 3). *Declared* is not *routed*:
+ `realtime` 6, `notification` 7, `i18n` 3). *Declared* is not *routed*:
`packages` is answered kernel-side by the `/packages` dispatcher domain over the
- ObjectQL registry, `i18n` has a kernel in-memory fallback, and `views` / `permissions` /
- `workflow` have no implementation anywhere — the rest wait on whatever fills the slot
+ ObjectQL registry, `i18n` has a kernel in-memory fallback, and `views` /
+ `permissions` have no implementation anywhere — the rest wait on whatever fills
+ the slot. (`workflow`'s 3 methods were the fourth such group; the whole slot
+ retired in v17, [#4451](https://github.com/objectstack-ai/objectstack/issues/4451).)
---
@@ -90,10 +92,12 @@ installable optional package, so they need no remedy (`NO_REMEDY_SLOTS` in the
guard script). And `null` there does **not** always mean "nothing ships" — it
means "no name belongs in an `Install X` sentence", which covers two cases:
-- **Nothing provides the slot at all** — `search`, `workflow` (and the retired
- `graphql`). Discovery says exactly that rather than naming a plausible
- package: `No implementation ships for the '' slot — register a service
- under it to enable`.
+- **Nothing provides the slot at all** — `search`. Discovery says exactly that
+ rather than naming a plausible package: `No implementation ships for the
+ '' slot — register a service under it to enable`. (`workflow` and the
+ never-real `graphql` sat here too until both were retired outright in v17,
+ [#4451](https://github.com/objectstack-ai/objectstack/issues/4451) — a slot
+ nothing fills and nothing consumes is better removed than explained.)
- **A provider exists but cannot be installed** — `ai`. `@objectstack/service-ai`
registers the slot in `objectstack-ai/cloud` and is `private: true`.
@@ -333,13 +337,18 @@ the domain answers **501** with that remedy spelled out, not a generic "install
plugin".
-### 6. workflow Service — 3 methods ❌ Nothing ships
-`getWorkflowConfig`, `getWorkflowState`, `workflowTransition`
-State machine transitions. No package registers the `workflow` slot
-(`CORE_SERVICE_PROVIDER.workflow` is `null`). Approve/reject are not workflow
-methods — per ADR-0019 they moved to the request-id-based approvals API under
-`/api/v1/approvals` (`POST /requests/:id/{approve,reject,recall}`, served by
-`@objectstack/plugin-approvals`).
+### 6. workflow Service — retired in v17
+The slot, its `IWorkflowService` contract and the three `WorkflowProtocol`
+methods (`getWorkflowConfig`, `getWorkflowState`, `workflowTransition`) were
+removed in [#4451](https://github.com/objectstack-ai/objectstack/issues/4451):
+nothing ever registered or resolved the slot (ADR-0115 Evidence 5), no method
+ever had an implementation, and no host ever mounted `/api/v1/workflow`. The
+three capabilities it named are live elsewhere — state-machine transitions are
+an object validation rule of type `state_machine`, approvals are `approval`
+flow nodes on the approvals runtime (ADR-0019 — decisions via
+`POST /api/v1/approvals/requests/:id/{approve,reject,recall}`, served by
+`@objectstack/plugin-approvals`), and record-triggered automation is lifecycle
+hooks + `record_change` flows.
### 7. automation Service — 1 method ✅ `@objectstack/service-automation`
`triggerAutomation`
@@ -502,11 +511,16 @@ a package that cannot be installed is a dead end, which is why
| Slot | State |
|:-------|:------------|
| **ui** | Nothing registers the slot. `ViewProtocol`'s five methods are declared and unrouted; view CRUD runs through `/api/v1/meta`, and `/api/v1/ui/view/:object` is served by the `protocol` service. |
-| **workflow** | Nothing ships. `WorkflowProtocol`'s three methods have no implementation and no consumer. |
| **search** | Nothing ships. Contract and engine enum exist in `@objectstack/spec` only. |
| **ai** | Nothing in this repo — `service-ai` (chat, completion, models, conversations) is Cloud/EE. |
| **realtime transport** | The service exists but no WebSocket/SSE route is mounted, so `routes.realtime` is deliberately never advertised. |
+The `workflow` slot used to sit in this table ("nothing ships, no consumer").
+It was retired outright in v17 (#4451, per ADR-0115 Evidence 5): the
+capability lives in `state_machine` validation rules, approval flow nodes
+(ADR-0019) and `record_change` flows, so there is nothing left for a slot to
+promise.
+
---
## Plugin Implementation Pattern
diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx
index c1be47021a..fbcaf5dcf8 100644
--- a/content/docs/protocol/kernel/http-protocol.mdx
+++ b/content/docs/protocol/kernel/http-protocol.mdx
@@ -47,14 +47,13 @@ GET /api/v1/discovery HTTP/1.1
"packages": "/api/v1/packages",
"auth": "/api/v1/auth",
"ui": "/api/v1/ui",
- "storage": "/api/v1/storage",
- "graphql": "/api/v1/graphql"
+ "storage": "/api/v1/storage"
},
"services": {
"data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" },
"metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" },
"auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" },
- "workflow": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'workflow' slot — register a service under it to enable" },
+ "search": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'search' slot — register a service under it to enable" },
"ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework" }
},
"locale": {
diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx
index 3b1d36b48b..f38ebe4b81 100644
--- a/content/docs/references/api/discovery.mdx
+++ b/content/docs/references/api/discovery.mdx
@@ -52,7 +52,6 @@ const result = ApiRoutes.parse(data);
| **storage** | `string` | optional | e.g. /api/v1/storage |
| **analytics** | `string` | optional | e.g. /api/v1/analytics |
| **packages** | `string` | optional | e.g. /api/v1/packages |
-| **workflow** | `string` | optional | e.g. /api/v1/workflow |
| **approvals** | `string` | optional | e.g. /api/v1/approvals |
| **realtime** | `string` | optional | e.g. /api/v1/realtime |
| **notifications** | `string` | optional | e.g. /api/v1/notifications |
diff --git a/content/docs/references/api/dispatcher.mdx b/content/docs/references/api/dispatcher.mdx
index 18d82d1b95..8cde2909a2 100644
--- a/content/docs/references/api/dispatcher.mdx
+++ b/content/docs/references/api/dispatcher.mdx
@@ -51,7 +51,7 @@ const result = DispatcherConfig.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **routes** | `{ prefix: string; service: Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>; authRequired: boolean; criticality: Enum<'required' \| 'core' \| 'optional'>; … }[]` | ✅ | Route-to-service mappings |
+| **routes** | `{ prefix: string; service: Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>; authRequired: boolean; criticality: Enum<'required' \| 'core' \| 'optional'>; … }[]` | ✅ | Route-to-service mappings |
| **fallback** | `Enum<'404' \| 'proxy' \| 'custom'>` | ✅ | Behavior when no route matches |
| **proxyTarget** | `string` | optional | Proxy target URL when fallback is "proxy" |
@@ -91,7 +91,7 @@ Route-resolution failure mode emitted in `error.code`
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **prefix** | `string` | ✅ | URL path prefix for routing (e.g. /api/v1/data) |
-| **service** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | Target core service name |
+| **service** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | Target core service name |
| **authRequired** | `boolean` | ✅ | Whether authentication is required |
| **criticality** | `Enum<'required' \| 'core' \| 'optional'>` | ✅ | Service criticality level for unavailability handling |
| **permissions** | `string[]` | optional | Required permissions for this route namespace |
diff --git a/content/docs/references/api/plugin-rest-api.mdx b/content/docs/references/api/plugin-rest-api.mdx
index f8c0b07a4a..78a8600c9e 100644
--- a/content/docs/references/api/plugin-rest-api.mdx
+++ b/content/docs/references/api/plugin-rest-api.mdx
@@ -227,7 +227,7 @@ const result = ErrorHandlingConfig.parse(data);
| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP method for this endpoint |
| **path** | `string` | ✅ | URL path pattern (e.g., /api/v1/data/:object/:id) |
| **handler** | `string` | ✅ | Protocol method name or handler identifier |
-| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Route category |
+| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Route category |
| **public** | `boolean` | ✅ | Is publicly accessible without authentication |
| **permissions** | `string[]` | optional | Required permissions (e.g., ["data.read", "object.account.read"]) |
| **summary** | `string` | optional | Short description for OpenAPI |
@@ -253,7 +253,7 @@ const result = ErrorHandlingConfig.parse(data);
| **enabled** | `boolean` | ✅ | Enable REST API plugin |
| **basePath** | `string` | ✅ | Base path for all API routes |
| **version** | `string` | ✅ | API version identifier |
-| **routes** | `{ prefix: string; service: string; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; methods?: string[]; … }[]` | ✅ | Route registrations |
+| **routes** | `{ prefix: string; service: string; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; methods?: string[]; … }[]` | ✅ | Route registrations |
| **validation** | `{ enabled: boolean; mode: Enum<'strict' \| 'permissive' \| 'strip'>; validateBody: boolean; validateQuery: boolean; … }` | optional | Request validation configuration |
| **responseEnvelope** | `{ enabled: boolean; includeMetadata: boolean; includeTimestamp: boolean; includeRequestId: boolean; … }` | optional | Response envelope configuration |
| **errorHandling** | `{ enabled: boolean; includeStackTrace: boolean; logErrors: boolean; exposeInternalErrors: boolean; … }` | optional | Error handling configuration |
@@ -276,7 +276,6 @@ const result = ErrorHandlingConfig.parse(data);
* `permission`
* `analytics`
* `automation`
-* `workflow`
* `ui`
* `realtime`
* `notification`
@@ -294,9 +293,9 @@ const result = ErrorHandlingConfig.parse(data);
| :--- | :--- | :--- | :--- |
| **prefix** | `string` | ✅ | URL path prefix for this route group |
| **service** | `string` | ✅ | Core service name (metadata, data, auth, etc.) |
-| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Primary category for this route group |
+| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Primary category for this route group |
| **methods** | `string[]` | optional | Protocol method names implemented |
-| **endpoints** | `{ method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; path: string; handler: string; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; … }[]` | optional | Endpoint definitions |
+| **endpoints** | `{ method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; path: string; handler: string; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; … }[]` | optional | Endpoint definitions |
| **middleware** | `{ name: string; type: Enum<'authentication' \| 'authorization' \| 'logging' \| 'validation' \| 'transformation' \| 'error' \| 'custom'>; enabled: boolean; order: integer; … }[]` | optional | Middleware stack for this route group |
| **authRequired** | `boolean` | ✅ | Whether authentication is required by default |
| **documentation** | `{ title?: string; description?: string; tags?: string[] }` | optional | Documentation metadata for this route group |
@@ -312,7 +311,7 @@ const result = ErrorHandlingConfig.parse(data);
| :--- | :--- | :--- | :--- |
| **path** | `string` | ✅ | Full URL path (e.g. /api/v1/analytics/query) |
| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP method (GET, POST, etc.) |
-| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Route category |
+| **category** | `Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>` | ✅ | Route category |
| **handlerStatus** | `Enum<'implemented' \| 'stub' \| 'planned'>` | ✅ | Handler status |
| **service** | `string` | ✅ | Target service name |
| **healthCheckPassed** | `boolean` | optional | Whether the health check probe succeeded |
@@ -329,7 +328,7 @@ const result = ErrorHandlingConfig.parse(data);
| **timestamp** | `string` | ✅ | ISO 8601 timestamp |
| **adapter** | `string` | ✅ | Adapter name (e.g. "hono", "express", "nextjs") |
| **summary** | `{ total: integer; implemented: integer; stub: integer; planned: integer }` | ✅ | |
-| **entries** | `{ path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'workflow' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; handlerStatus: Enum<'implemented' \| 'stub' \| 'planned'>; … }[]` | ✅ | Per-endpoint coverage entries |
+| **entries** | `{ path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; category: Enum<'discovery' \| 'metadata' \| 'data' \| 'batch' \| 'permission' \| 'analytics' \| 'automation' \| 'ui' \| 'realtime' \| 'notification' \| 'ai' \| 'i18n'>; handlerStatus: Enum<'implemented' \| 'stub' \| 'planned'>; … }[]` | ✅ | Per-endpoint coverage entries |
---
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index fd59ab995f..bd77bbecb9 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -20,8 +20,8 @@ validation. Each entry is a canonical `ActionDescriptorSchema`.
## TypeScript Usage
```typescript
-import { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
-import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
+import { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse } from '@objectstack/spec/api';
+import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse } from '@objectstack/spec/api';
// Validate data
const result = AiAgentCapabilities.parse(data);
@@ -923,54 +923,6 @@ const result = AiAgentCapabilities.parse(data);
| **view** | `{ list?: object; form?: object; listViews?: Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>; formViews?: Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` | ✅ | View definition |
----
-
-## GetWorkflowConfigRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name to get workflow config for |
-
-
----
-
-## GetWorkflowConfigResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **workflows** | `{ id: string; description?: string; contextSchema?: Record; initial: string; … }[]` | ✅ | Active state-machine workflows for this object |
-
-
----
-
-## GetWorkflowStateRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID to get workflow state for |
-
-
----
-
-## GetWorkflowStateResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID |
-| **state** | `{ currentState: string; availableTransitions: { name: string; targetState: string; label?: string; requiresApproval: boolean }[]; history?: { fromState: string; toState: string; action: string; userId: string; … }[] }` | ✅ | Current workflow state and available transitions |
-
-
---
## HttpFindQueryParams
@@ -1476,45 +1428,3 @@ const result = AiAgentCapabilities.parse(data);
---
-## WorkflowState
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **currentState** | `string` | ✅ | Current workflow state name |
-| **availableTransitions** | `{ name: string; targetState: string; label?: string; requiresApproval: boolean }[]` | ✅ | Available transitions from current state |
-| **history** | `{ fromState: string; toState: string; action: string; userId: string; … }[]` | optional | State transition history |
-
-
----
-
-## WorkflowTransitionRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID |
-| **transition** | `string` | ✅ | Transition name to execute |
-| **comment** | `string` | optional | Optional comment for the transition |
-| **data** | `Record` | optional | Additional data for the transition |
-
-
----
-
-## WorkflowTransitionResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID |
-| **success** | `boolean` | ✅ | Whether the transition succeeded |
-| **state** | `{ currentState: string; availableTransitions: { name: string; targetState: string; label?: string; requiresApproval: boolean }[]; history?: { fromState: string; toState: string; action: string; userId: string; … }[] }` | ✅ | New workflow state after transition |
-
-
----
-
diff --git a/content/docs/references/system/core-services.mdx b/content/docs/references/system/core-services.mdx
index 5a28e0fba2..fd17fb5884 100644
--- a/content/docs/references/system/core-services.mdx
+++ b/content/docs/references/system/core-services.mdx
@@ -52,7 +52,6 @@ const result = CoreServiceName.parse(data);
* `ai`
* `i18n`
* `ui`
-* `workflow`
---
@@ -67,7 +66,7 @@ const result = CoreServiceName.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | |
-| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | |
+| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | |
| **options** | `Record` | optional | |
@@ -90,7 +89,7 @@ const result = CoreServiceName.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | |
+| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui'>` | ✅ | |
| **enabled** | `boolean` | ✅ | |
| **status** | `Enum<'running' \| 'stopped' \| 'degraded' \| 'initializing'>` | ✅ | |
| **version** | `string` | optional | |
diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx
index f71a3c9550..8cac880ebe 100644
--- a/content/docs/releases/v17.mdx
+++ b/content/docs/releases/v17.mdx
@@ -1015,6 +1015,7 @@ import or the authored key.
| `DEFAULT_DISPATCHER_ROUTES` | dead route table |
| Aspirational config on Theme / Translation / Webhook | still-dead after #3494 |
| `ChartInteraction.zoom` / `.clickAction` | never implemented (#3752) |
+| The `workflow` service slot — `CoreServiceName 'workflow'`, `IWorkflowService`, `WorkflowProtocol`, the `Get/WorkflowState/Config/Transition` schema cluster, discovery `routes.workflow` / `services.workflow` / `features.workflow`, the `RestApiRouteCategory 'workflow'` member and the stray `graphql` provider entry | declared end to end and implemented nowhere: nothing ever registered or resolved the slot (ADR-0115 Evidence 5), no method of `WorkflowProtocol` was ever implemented, no host ever mounted `/api/v1/workflow`. State machines are `state_machine` validation rules; approvals are flow nodes (ADR-0019); record-triggered automation is hooks + `record_change` flows (#4451) |
| `datasource.readReplicas` | replica connections nothing ever opened — no driver reads the key and no query path splits reads from writes, so every statement went to the primary. #4410 had just taught the schema to validate each entry against the declared driver's contract, which made a dead slot look rigorously alive (#4468) |
The Console side follows: `@object-ui/types` drops its
@@ -2088,6 +2089,13 @@ covers are folded into the list below rather than left to the changelog.)
device/preference helpers, `client.ai.{nlq,suggest,insights}` and
`projects.listTemplates()`; repoint marketplace publish to
`POST /api/v1/packages/publish`; drop `os environments create --template`.
+ Imports of `IWorkflowService`, `WorkflowProtocol` or the
+ `Get/WorkflowState/Config/Transition` types no longer resolve — the
+ `workflow` slot retired with them (#4451); use `state_machine` validation
+ rules, approval flow nodes and `record_change` flows instead. Discovery
+ responses no longer carry `services.workflow` / `routes.workflow` /
+ `features.workflow` — a reader keying on them saw only `unavailable`/`false`
+ before, so delete the read.
- **Console hosts:** the same removals land in objectui — drop the
`useClientNotifications` device/preference delegates (`@object-ui/react`) and
replace the retired `@object-ui/types` Capabilities re-exports with imports
diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md
index 49ec3e58a3..5510962613 100644
--- a/docs/protocol-upgrade-guide.md
+++ b/docs/protocol-upgrade-guide.md
@@ -219,6 +219,9 @@ Closing the same audit on the data side, `datasource.readReplicas` is removed (#
- **`query-distinct-retired`** — `data.query.distinct` → `groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values
- Why not automatic: The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that "confirmed" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286.
- Done when: No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; deduplication goes through `groupBy` / `count_distinct` / the drivers' `distinct()` door. A query still carrying the key fails to parse with the removal prescription, and the REST list response reports a real `total` for queries that used to send it.
+- **`workflow-service-slot-retired`** — `CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow` → the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation
+ - Why not automatic: The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451.
+ - Done when: No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/Transition types resolves; no code calls getService('workflow') or reads discovery `routes.workflow` / `services.workflow`; record state machines, approvals and record-triggered automation go through the replacement mechanisms. Discovery output on a default boot is unchanged (the slot was always reported unavailable; now it is simply absent).
---
diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts
index 03ba3f6042..271905f8b9 100644
--- a/packages/cli/src/commands/explain.ts
+++ b/packages/cli/src/commands/explain.ts
@@ -258,32 +258,23 @@ export const SCHEMAS: Record = {
docsPath: 'ui/action',
},
+ // Kept as a redirect topic (mirroring content/docs/automation/workflows.mdx):
+ // there is NO standalone Workflow authoring type. The shape this entry used
+ // to teach (states[]/transitions[]/approvers) never existed in the spec —
+ // ADR-0019 folded approval processes into Flow, and the workflow service
+ // slot itself retired in #4451 (v17).
workflow: {
- name: 'Workflow',
- description: 'State machine and approval process that governs record lifecycle transitions.',
- required: [
- { name: 'name', type: 'string (snake_case)', description: 'Machine name identifier' },
- { name: 'object', type: 'string', description: 'Target object' },
- ],
- optional: [
- { name: 'label', type: 'string', description: 'Display name' },
- { name: 'states', type: 'State[]', description: 'Defined workflow states' },
- { name: 'transitions', type: 'Transition[]', description: 'Allowed state transitions' },
- { name: 'approvers', type: 'ApproverConfig', description: 'Approval chain configuration' },
- ],
- example: `{
- name: 'task_approval',
- object: 'project_task',
- label: 'Task Approval',
- states: ['draft', 'pending', 'approved', 'rejected'],
- transitions: [
- { from: 'draft', to: 'pending', action: 'submit' },
- { from: 'pending', to: 'approved', action: 'approve' },
- { from: 'pending', to: 'rejected', action: 'reject' },
- ],
-}`,
+ name: 'Workflow (no standalone type)',
+ description: 'ObjectStack has no standalone Workflow authoring type. Use Flow for event-triggered or scheduled automation, an object validation rule of type "state_machine" for strict lifecycle transitions, and Approval nodes inside a flow for human approval pauses (ADR-0019).',
+ required: [],
+ optional: [],
+ example: `// No workflow metadata exists. Compose the live mechanisms instead:
+// - Flow (type: 'record_change' | 'schedule' | 'screen') for automation
+// - object validation rule { type: 'state_machine', ... } for transitions
+// - a flow node of type 'approval' for human approval steps
+// See: os explain flow`,
related: ['object', 'flow', 'action'],
- docsPath: 'automation/workflow',
+ docsPath: 'automation/workflows',
},
trigger: {
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 29b139e26a..dcb3a9a28f 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -4542,7 +4542,8 @@ export class ObjectStackClient {
automation: '/api/v1/automation',
packages: '/api/v1/packages',
realtime: '/api/v1/realtime',
- workflow: '/api/v1/workflow',
+ // `workflow` removed (#4451, v17): the slot retired with the ApiRoutes
+ // field — there was never a surface behind the convention.
approvals: '/api/v1/approvals',
notifications: '/api/v1/notifications',
ai: '/api/v1/ai',
@@ -4963,10 +4964,8 @@ export type {
RealtimeSubscribeRequest,
RealtimeSubscribeResponse,
GetPresenceResponse,
- GetWorkflowConfigResponse,
- GetWorkflowStateResponse,
- WorkflowTransitionRequest,
- WorkflowTransitionResponse,
+ // Workflow re-exports removed (#4451, v17): the types were deleted from
+ // @objectstack/spec/api with the retired workflow slot.
ListViewsResponse,
GetViewResponse,
CreateViewResponse,
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index c43dd79b8a..8ec5dede16 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -1247,7 +1247,10 @@ const SERVICE_CONFIG: Record
expect(discovery.services.auth.enabled).toBe(false);
expect(discovery.services.auth.status).toBe('unavailable');
expect(discovery.services.auth.message).toContain('plugin-auth');
- // capabilities removed — derive from services
- expect(discovery.services.workflow).toBeDefined();
- expect(discovery.services.workflow.enabled).toBe(false);
+ // capabilities removed — derive from services. (`workflow` was the slot
+ // pinned here until it retired in #4451; it must now be absent entirely.)
+ expect(discovery.services).not.toHaveProperty('workflow');
});
it('should return available auth service when auth is registered', async () => {
@@ -109,18 +109,20 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
});
it('should report a __serviceInfo-marked service with its declared status', async () => {
+ // Was pinned on `workflow` until that slot retired (#4451, v17); `auth`
+ // exercises the same marked-service path.
const mockServices = new Map();
- mockServices.set('workflow', {
+ mockServices.set('auth', {
__serviceInfo: { status: 'degraded', message: 'partial impl' },
});
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();
- expect(discovery.services.workflow.enabled).toBe(true);
- expect(discovery.services.workflow.status).toBe('degraded');
- expect(discovery.services.workflow.handlerReady).toBe(true);
- expect(discovery.services.workflow.message).toBe('partial impl');
+ expect(discovery.services.auth.enabled).toBe(true);
+ expect(discovery.services.auth.status).toBe('degraded');
+ expect(discovery.services.auth.handlerReady).toBe(true);
+ expect(discovery.services.auth.message).toBe('partial impl');
});
it('should report a registered analytics service with its self-declared status', async () => {
@@ -332,13 +334,14 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
expect(discovery.routes.i18n).toBe('/api/v1/i18n');
});
- // Not every SERVICE_CONFIG entry is dispatcher-owned: `search` (REST layer),
- // `workflow` and `graphql` have their routes mounted by the plugin that
- // registers the service, so `handlerReady` there says nothing about whether
- // THAT route is mounted and the advertisement stays presence-gated.
- // Suppressing it would be a guess, not honesty. (cache/queue/job used to be
- // named here on the same theory, but nothing mounts routes for them at all —
- // they are route-less kernel-internal slots since #4318.)
+ // Not every SERVICE_CONFIG entry is dispatcher-owned: `search` (REST
+ // layer) has its route mounted by the plugin that registers the service,
+ // so `handlerReady` there says nothing about whether THAT route is mounted
+ // and the advertisement stays presence-gated. Suppressing it would be a
+ // guess, not honesty. (cache/queue/job used to be named here on the same
+ // theory, but nothing mounts routes for them at all — route-less
+ // kernel-internal slots since #4318; `workflow` and `graphql` retired
+ // outright in #4451.)
it('should leave non-dispatcher-owned routes presence-gated', async () => {
const mockServices = new Map();
mockServices.set('search', { __serviceInfo: { status: 'stub', message: 'dev fake' } });
@@ -433,17 +436,19 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
});
it('should return capabilities field populated from registered services', async () => {
+ // Was pinned on `workflow` until that slot retired (#4451, v17); `ui`
+ // is likewise registered without mapping to a well-known capability.
const mockServices = new Map();
- mockServices.set('workflow', {});
-
+ mockServices.set('ui', {});
+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();
-
+
// capabilities field should now exist in the response
expect(discovery.capabilities).toBeDefined();
- // workflow is registered but doesn't map to a well-known capability directly
- expect(discovery.services.workflow.enabled).toBe(true);
- // All well-known capabilities should be disabled since workflow doesn't map to any
+ // ui is registered but doesn't map to a well-known capability directly
+ expect(discovery.services.ui.enabled).toBe(true);
+ // All well-known capabilities should be disabled since ui doesn't map to any
// (comments derives from the sys_comment object, which is not registered here).
expect(discovery.capabilities!.comments).toEqual({ enabled: false });
expect(discovery.capabilities!.automation).toEqual({ enabled: false });
diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts
index f3ac0d8d5a..41507b0568 100644
--- a/packages/runtime/src/http-dispatcher.test.ts
+++ b/packages/runtime/src/http-dispatcher.test.ts
@@ -2326,12 +2326,20 @@ describe('HttpDispatcher', () => {
it('says nothing ships rather than naming a package that does not exist', async () => {
const info = await dispatcher.getDiscoveryInfo('/api/v1');
- for (const slot of ['ai', 'search', 'workflow'] as const) {
+ // `workflow` left this list with its slot (#4451, v17).
+ for (const slot of ['ai', 'search'] as const) {
expect(info.services[slot].message, `services.${slot}.message`).not.toMatch(/Install/);
expect(info.services[slot].message, `services.${slot}.message`).toContain(slot);
}
});
+ it('reports no entry at all for the retired workflow slot (#4451)', async () => {
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
+ expect(info.services).not.toHaveProperty('workflow');
+ expect(info.routes).not.toHaveProperty('workflow');
+ expect(info.features).not.toHaveProperty('workflow');
+ });
+
it('never emits the old slot-name-derived template', async () => {
const info = await dispatcher.getDiscoveryInfo('/api/v1');
for (const [slot, entry] of Object.entries(info.services as Record)) {
@@ -2520,15 +2528,17 @@ describe('HttpDispatcher', () => {
});
it('keeps reporting unmarked services as available', async () => {
+ // Was pinned on `workflow` until that slot retired (#4451, v17);
+ // `auth` exercises the same unmarked-service path.
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
- if (name === 'workflow') return { getConfig: vi.fn() };
+ if (name === 'auth') return { validateToken: vi.fn() };
return null;
});
const info = await dispatcher.getDiscoveryInfo('/api/v1');
- expect(info.services.workflow.enabled).toBe(true);
- expect(info.services.workflow.status).toBe('available');
- expect(info.services.workflow.handlerReady).toBe(true);
+ expect(info.services.auth.enabled).toBe(true);
+ expect(info.services.auth.status).toBe('available');
+ expect(info.services.auth.handlerReady).toBe(true);
});
// ── The `metadata` slot: computed, not hardcoded (#4089) ──────────────
diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts
index 88a90aefb9..23887a75d9 100644
--- a/packages/runtime/src/http-dispatcher.ts
+++ b/packages/runtime/src/http-dispatcher.ts
@@ -876,7 +876,7 @@ export class HttpDispatcher {
// that request handlers (handleI18n, handleAuth, …) use.
const [
authSvc, searchSvc, realtimeSvc, filesSvc,
- analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc,
+ analyticsSvc, aiSvc, notificationSvc, i18nSvc,
protocolSvc, automationSvc, cacheSvc, queueSvc, jobSvc, mcpSvc,
metadataSvc, dataSvc,
] = await Promise.all([
@@ -885,7 +885,6 @@ export class HttpDispatcher {
this.resolveService(CoreServiceName.enum.realtime),
this.resolveService(CoreServiceName.enum['file-storage']),
this.resolveService(CoreServiceName.enum.analytics),
- this.resolveService(CoreServiceName.enum.workflow),
this.resolveService(CoreServiceName.enum.ai),
this.resolveService(CoreServiceName.enum.notification),
this.resolveService(CoreServiceName.enum.i18n),
@@ -943,7 +942,6 @@ export class HttpDispatcher {
const automationRegistered = !!automationSvc;
const hasFiles = isServiceServeable(filesSvc);
const hasAnalytics = isServiceServeable(analyticsSvc);
- const hasWorkflow = !!workflowSvc;
const hasAi = isServiceServeable(aiSvc);
const hasNotification = isServiceServeable(notificationSvc);
const hasI18n = isServiceServeable(i18nSvc);
@@ -977,7 +975,9 @@ export class HttpDispatcher {
storage: hasFiles ? `${prefix}/storage` : undefined,
analytics: hasAnalytics ? `${prefix}/analytics` : undefined,
automation: hasAutomation ? `${prefix}/automation` : undefined,
- workflow: hasWorkflow ? `${prefix}/workflow` : undefined,
+ // `workflow` removed (#4451, v17): the slot retired — nothing
+ // ever registered it and this dispatcher never had a /workflow
+ // branch, so the advertisement could never come true.
// Never advertised (ADR-0076 D12, #2462): service-realtime is an
// in-process pub/sub bus — the dispatcher has no /realtime branch
// and no plugin mounts one, so an advertised route would 404.
@@ -1090,7 +1090,6 @@ export class HttpDispatcher {
files: hasFiles,
analytics: hasAnalytics,
ai: hasAi,
- workflow: hasWorkflow,
notifications: hasNotification,
i18n: hasI18n,
},
@@ -1181,7 +1180,8 @@ export class HttpDispatcher {
enabled: false, status: 'unavailable' as const, handlerReady: false,
message: serviceUnavailableMessage('ui'),
},
- workflow: hasWorkflow ? svcAvailable(routes.workflow, undefined, workflowSvc) : svcUnavailable('workflow'),
+ // `workflow` entry removed (#4451, v17) with the slot itself —
+ // it could only ever report `unavailable`.
// Honest entry (ADR-0076 D12, #2462): the registered realtime
// service is an in-process event bus with NO mounted HTTP/WS
// surface — report it degraded with handlerReady:false (or as
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index e45d9a700c..b8a2cdca4a 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -2785,14 +2785,6 @@
"GetViewRequestSchema (const)",
"GetViewResponse (type)",
"GetViewResponseSchema (const)",
- "GetWorkflowConfigRequest (type)",
- "GetWorkflowConfigRequestSchema (const)",
- "GetWorkflowConfigResponse (type)",
- "GetWorkflowConfigResponseSchema (const)",
- "GetWorkflowStateRequest (type)",
- "GetWorkflowStateRequestSchema (const)",
- "GetWorkflowStateResponse (type)",
- "GetWorkflowStateResponseSchema (const)",
"HandlerStatus (type)",
"HandlerStatusSchema (const)",
"HttpFindQueryParamsSchema (const)",
@@ -3243,13 +3235,6 @@
"WebhookEventSchema (const)",
"WellKnownCapabilities (type)",
"WellKnownCapabilitiesSchema (const)",
- "WorkflowProtocol (interface)",
- "WorkflowState (type)",
- "WorkflowStateSchema (const)",
- "WorkflowTransitionRequest (type)",
- "WorkflowTransitionRequestSchema (const)",
- "WorkflowTransitionResponse (type)",
- "WorkflowTransitionResponseSchema (const)",
"envelopeViolations (function)",
"getAuthEndpointUrl (function)",
"getDefaultRouteRegistrations (function)",
@@ -3808,7 +3793,6 @@
"IStorageService (interface)",
"ITeamGraphService (interface)",
"ITenantRouter (interface)",
- "IWorkflowService (interface)",
"ImportObjectOpts (interface)",
"ImportObjectResult (interface)",
"InboxListResult (interface)",
@@ -3944,9 +3928,6 @@
"UploadArtifactResult (interface)",
"UserModelMessage (type)",
"ValidationResult (interface)",
- "WorkflowStatus (interface)",
- "WorkflowTransition (interface)",
- "WorkflowTransitionResult (interface)",
"WriteObservabilityOptions (interface)"
],
"./integration": [
diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json
index 1309c98404..a5778751aa 100644
--- a/packages/spec/authorable-surface.json
+++ b/packages/spec/authorable-surface.json
@@ -589,7 +589,6 @@
"api/ApiRoutes:realtime",
"api/ApiRoutes:storage",
"api/ApiRoutes:ui",
- "api/ApiRoutes:workflow",
"api/ApiTestCollection:description",
"api/ApiTestCollection:folders",
"api/ApiTestCollection:name",
@@ -1205,14 +1204,6 @@
"api/GetViewRequest:viewId",
"api/GetViewResponse:object",
"api/GetViewResponse:view",
- "api/GetWorkflowConfigRequest:object",
- "api/GetWorkflowConfigResponse:object",
- "api/GetWorkflowConfigResponse:workflows",
- "api/GetWorkflowStateRequest:object",
- "api/GetWorkflowStateRequest:recordId",
- "api/GetWorkflowStateResponse:object",
- "api/GetWorkflowStateResponse:recordId",
- "api/GetWorkflowStateResponse:state",
"api/HttpFindQueryParams:count",
"api/HttpFindQueryParams:distinct [RETIRED]",
"api/HttpFindQueryParams:expand",
@@ -2121,18 +2112,6 @@
"api/WellKnownCapabilities:export",
"api/WellKnownCapabilities:search",
"api/WellKnownCapabilities:transactionalBatch",
- "api/WorkflowState:availableTransitions",
- "api/WorkflowState:currentState",
- "api/WorkflowState:history",
- "api/WorkflowTransitionRequest:comment",
- "api/WorkflowTransitionRequest:data",
- "api/WorkflowTransitionRequest:object",
- "api/WorkflowTransitionRequest:recordId",
- "api/WorkflowTransitionRequest:transition",
- "api/WorkflowTransitionResponse:object",
- "api/WorkflowTransitionResponse:recordId",
- "api/WorkflowTransitionResponse:state",
- "api/WorkflowTransitionResponse:success",
"automation/ActionDescriptor:aliasOf",
"automation/ActionDescriptor:category",
"automation/ActionDescriptor:configSchema",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index 991c7e8330..06de9b650f 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -1,5 +1,5 @@
{
- "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema — remove a key ONLY for a deliberate retirement. See #2978.",
+ "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema \u2014 remove a key ONLY for a deliberate retirement. See #2978.",
"schemas": [
"ai/AIModelConfig",
"ai/AIUsageRecord",
@@ -272,10 +272,6 @@
"api/GetUiViewResponse",
"api/GetViewRequest",
"api/GetViewResponse",
- "api/GetWorkflowConfigRequest",
- "api/GetWorkflowConfigResponse",
- "api/GetWorkflowStateRequest",
- "api/GetWorkflowStateResponse",
"api/HandlerStatus",
"api/HttpFindQueryParams",
"api/HttpMethod",
@@ -499,9 +495,6 @@
"api/WebhookConfig",
"api/WebhookEvent",
"api/WellKnownCapabilities",
- "api/WorkflowState",
- "api/WorkflowTransitionRequest",
- "api/WorkflowTransitionResponse",
"automation/ActionCategory",
"automation/ActionDescriptor",
"automation/ActionParadigm",
diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json
index af4642b007..fb5df1b4ac 100644
--- a/packages/spec/spec-changes.json
+++ b/packages/spec/spec-changes.json
@@ -353,6 +353,13 @@
"migrationId": "query-distinct-retired",
"toMajor": 17,
"rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
+ },
+ {
+ "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow",
+ "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation",
+ "migrationId": "workflow-service-slot-retired",
+ "toMajor": 17,
+ "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451."
}
],
"removed": []
@@ -765,6 +772,13 @@
"migrationId": "query-distinct-retired",
"toMajor": 17,
"rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
+ },
+ {
+ "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow",
+ "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation",
+ "migrationId": "workflow-service-slot-retired",
+ "toMajor": 17,
+ "rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451."
}
],
"removed": []
diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts
index a926fef9e2..70511090a3 100644
--- a/packages/spec/src/api/discovery.zod.ts
+++ b/packages/spec/src/api/discovery.zod.ts
@@ -178,8 +178,10 @@ export const ApiRoutesSchema = lazySchema(() => z.object({
/** Base URL for Package Management */
packages: z.string().optional().describe('e.g. /api/v1/packages'),
- /** Base URL for Workflow Engine */
- workflow: z.string().optional().describe('e.g. /api/v1/workflow'),
+ // `workflow` was removed here (#4451, v17): no host ever mounted a workflow
+ // surface and nothing ever registered the slot (ADR-0115 Evidence 5), so no
+ // builder could truthfully populate the field. State machines are enforced
+ // by the `state_machine` validation rule; approvals live below.
/** Base URL for Approvals (ADR-0019: approval as a flow node) */
approvals: z.string().optional().describe('e.g. /api/v1/approvals'),
diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts
index fecd761a81..d0e0073421 100644
--- a/packages/spec/src/api/plugin-rest-api.zod.ts
+++ b/packages/spec/src/api/plugin-rest-api.zod.ts
@@ -81,7 +81,9 @@ export const RestApiRouteCategory = z.enum([
'permission', // Permission/authorization checks
'analytics', // Analytics and reporting
'automation', // Automation triggers and flows
- 'workflow', // Workflow state management
+ // 'workflow' removed (#4451, v17): no workflow surface ever existed for a
+ // route to belong to (ADR-0115 Evidence 5); state machines are a validation
+ // rule, approvals are flow nodes. Routes in that space are 'automation'.
'ui', // UI metadata (views, layouts)
'realtime', // Realtime/WebSocket
'notification', // Notification management
diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts
index 6990d26137..5533727535 100644
--- a/packages/spec/src/api/protocol.test.ts
+++ b/packages/spec/src/api/protocol.test.ts
@@ -28,12 +28,7 @@ import {
GetObjectPermissionsRequestSchema,
GetObjectPermissionsResponseSchema,
GetEffectivePermissionsResponseSchema,
- // Workflows
- GetWorkflowConfigRequestSchema,
- WorkflowStateSchema,
- GetWorkflowStateRequestSchema,
- WorkflowTransitionRequestSchema,
- WorkflowTransitionResponseSchema,
+ // Workflow schemas removed with the retired slot (#4451, v17)
// Realtime
RealtimeConnectRequestSchema,
RealtimeConnectResponseSchema,
@@ -219,27 +214,9 @@ describe('ObjectStack Protocol', () => {
}
});
- it('validates Workflow operations', () => {
- expect(GetWorkflowConfigRequestSchema.safeParse({ object: 'lead' }).success).toBe(true);
- const state = {
- currentState: 'open',
- availableTransitions: [
- { name: 'approve', targetState: 'approved', label: 'Approve', requiresApproval: true },
- ],
- history: [{
- fromState: 'draft', toState: 'open', action: 'submit',
- userId: 'u1', timestamp: '2024-01-15T10:00:00Z',
- }],
- };
- expect(WorkflowStateSchema.safeParse(state).success).toBe(true);
- expect(GetWorkflowStateRequestSchema.safeParse({ object: 'lead', recordId: 'l1' }).success).toBe(true);
- expect(WorkflowTransitionRequestSchema.safeParse({
- object: 'lead', recordId: 'l1', transition: 'approve', comment: 'Looks good',
- }).success).toBe(true);
- expect(WorkflowTransitionResponseSchema.safeParse({
- object: 'lead', recordId: 'l1', success: true, state,
- }).success).toBe(true);
- });
+ // 'validates Workflow operations' removed (#4451, v17): the workflow
+ // schemas were deleted with the retired slot — nothing ever implemented
+ // the protocol they described.
it('validates Realtime operations', () => {
expect(RealtimeConnectRequestSchema.safeParse({
diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts
index 33d3f5c62c..e8c46afaa1 100644
--- a/packages/spec/src/api/protocol.zod.ts
+++ b/packages/spec/src/api/protocol.zod.ts
@@ -21,7 +21,6 @@ import {
} from './analytics.zod';
import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod';
import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod';
-import { StateMachineSchema } from '../automation/state-machine.zod';
import { ActionDescriptorSchema } from '../automation/node-executor.zod';
import { TranslationDataSchema } from '../system/translation.zod';
import {
@@ -684,67 +683,20 @@ export const GetEffectivePermissionsResponseSchema = lazySchema(() => z.object({
}));
// ==========================================
-// Workflow Operations
+// Workflow Operations — REMOVED (#4451, v17)
// ==========================================
-
-export const GetWorkflowConfigRequestSchema = lazySchema(() => z.object({
- object: z.string().describe('Object name to get workflow config for'),
-}));
-
-export const GetWorkflowConfigResponseSchema = lazySchema(() => z.object({
- object: z.string().describe('Object name'),
- workflows: z.array(StateMachineSchema).describe('Active state-machine workflows for this object'),
-}));
-
-export const WorkflowStateSchema = lazySchema(() => z.object({
- currentState: z.string().describe('Current workflow state name'),
- availableTransitions: z.array(z.object({
- name: z.string().describe('Transition name'),
- targetState: z.string().describe('Target state after transition'),
- label: z.string().optional().describe('Display label'),
- requiresApproval: z.boolean().default(false).describe('Whether transition requires approval'),
- })).describe('Available transitions from current state'),
- history: z.array(z.object({
- fromState: z.string().describe('Previous state'),
- toState: z.string().describe('New state'),
- action: z.string().describe('Action that triggered the transition'),
- userId: z.string().describe('User who performed the action'),
- timestamp: z.string().datetime().describe('When the transition occurred'),
- comment: z.string().optional().describe('Optional comment'),
- })).optional().describe('State transition history'),
-}));
-
-export const GetWorkflowStateRequestSchema = lazySchema(() => z.object({
- object: z.string().describe('Object name'),
- recordId: z.string().describe('Record ID to get workflow state for'),
-}));
-
-export const GetWorkflowStateResponseSchema = lazySchema(() => z.object({
- object: z.string().describe('Object name'),
- recordId: z.string().describe('Record ID'),
- state: WorkflowStateSchema.describe('Current workflow state and available transitions'),
-}));
-
-export const WorkflowTransitionRequestSchema = lazySchema(() => z.object({
- object: z.string().describe('Object name'),
- recordId: z.string().describe('Record ID'),
- transition: z.string().describe('Transition name to execute'),
- comment: z.string().optional().describe('Optional comment for the transition'),
- data: z.record(z.string(), z.unknown()).optional().describe('Additional data for the transition'),
-}));
-
-export const WorkflowTransitionResponseSchema = lazySchema(() => z.object({
- object: z.string().describe('Object name'),
- recordId: z.string().describe('Record ID'),
- success: z.boolean().describe('Whether the transition succeeded'),
- state: WorkflowStateSchema.describe('New workflow state after transition'),
-}));
-
-// ADR-0019: approval is no longer a workflow step. The approve/reject surface
-// moved off `workflow` onto the dedicated approvals runtime (a flow's Approval
-// node opens a request and suspends; a decision resumes it). Decisions are
-// recorded via `POST /approvals/requests/:id/{approve,reject}`, not on a
-// workflow record. `workflow` is reclaimed for state machines (transitions).
+// The Get/WorkflowState/Config/Transition schemas and the `WorkflowProtocol`
+// interface were deleted: no code ever implemented any of the three methods,
+// nothing ever registered the `workflow` service slot they notionally fronted
+// (ADR-0115 Evidence 5 — "no code in this repository resolves either slot",
+// verified across both repositories), and no HTTP surface ever mounted
+// `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among
+// routes that never existed). The capability the wrappers promised is live
+// elsewhere: record state machines are enforced by the `state_machine`
+// validation rule (`StateMachineSchema` stays authorable on the object),
+// approvals are first-class flow nodes on the approvals runtime (ADR-0019 —
+// decisions via `POST /approvals/requests/:id/{approve,reject}`), and
+// record-triggered automation is lifecycle hooks + `record_change` flows.
// ==========================================
// Realtime Operations
@@ -1312,14 +1264,8 @@ export type GetObjectPermissionsResponse = z.infer;
export type GetEffectivePermissionsResponse = z.infer;
-// Workflow Types
-export type GetWorkflowConfigRequest = z.input;
-export type GetWorkflowConfigResponse = z.infer;
-export type WorkflowState = z.infer;
-export type GetWorkflowStateRequest = z.input;
-export type GetWorkflowStateResponse = z.infer;
-export type WorkflowTransitionRequest = z.input;
-export type WorkflowTransitionResponse = z.infer;
+// Workflow Types — removed with the schemas (#4451, v17); see the block comment
+// at "Workflow Operations — REMOVED" above.
// Realtime Types
export type RealtimeConnectRequest = z.input;
@@ -1498,12 +1444,9 @@ export interface PermissionProtocol {
getEffectivePermissions?(request: GetEffectivePermissionsRequest): Promise;
}
-/** Workflows (optional). */
-export interface WorkflowProtocol {
- getWorkflowConfig?(request: GetWorkflowConfigRequest): Promise;
- getWorkflowState?(request: GetWorkflowStateRequest): Promise;
- workflowTransition?(request: WorkflowTransitionRequest): Promise;
-}
+// `WorkflowProtocol` (optional sub-protocol) was removed here (#4451, v17):
+// no implementation of any of its three methods ever existed. See the
+// "Workflow Operations — REMOVED" block comment above.
/** Realtime / presence (optional). */
export interface RealtimeProtocol {
diff --git a/packages/spec/src/api/router.zod.ts b/packages/spec/src/api/router.zod.ts
index ffadc9cb56..37dcff7db0 100644
--- a/packages/spec/src/api/router.zod.ts
+++ b/packages/spec/src/api/router.zod.ts
@@ -90,7 +90,8 @@ export const RouterConfigSchema = lazySchema(() => z.object({
storage: z.string().default('/storage').describe('Storage Protocol'),
analytics: z.string().default('/analytics').describe('Analytics Protocol'),
ui: z.string().default('/ui').describe('UI Metadata Protocol (Views, Layouts)'),
- workflow: z.string().default('/workflow').describe('Workflow Engine Protocol'),
+ // `workflow` mount removed (#4451, v17): no workflow surface ever existed
+ // to mount (ADR-0115 Evidence 5).
realtime: z.string().default('/realtime').describe('Realtime/WebSocket Protocol'),
notifications: z.string().default('/notifications').describe('Notification Protocol'),
ai: z.string().default('/ai').describe('AI Engine Protocol (NLQ, Chat, Suggest)'),
@@ -104,7 +105,6 @@ export const RouterConfigSchema = lazySchema(() => z.object({
storage: '/storage',
analytics: '/analytics',
ui: '/ui',
- workflow: '/workflow',
realtime: '/realtime',
notifications: '/notifications',
ai: '/ai',
diff --git a/packages/spec/src/contracts/core-service-contracts.test.ts b/packages/spec/src/contracts/core-service-contracts.test.ts
index 0c4049f0c2..340358a255 100644
--- a/packages/spec/src/contracts/core-service-contracts.test.ts
+++ b/packages/spec/src/contracts/core-service-contracts.test.ts
@@ -29,10 +29,11 @@ describe('CoreServiceName → contract map (#4127)', () => {
// The map's keys are checked against the enum at compile time below;
// this asserts the same thing at runtime so a rename shows up as a
// failing test and not only as a type error in an unrelated package.
+ // ('workflow' left both lists with its slot, #4451 v17.)
const mapped: Array = [
'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue',
'automation', 'analytics', 'realtime', 'job', 'notification', 'ai',
- 'i18n', 'workflow',
+ 'i18n',
];
const slots = new Set(CoreServiceName.options);
for (const key of mapped) {
@@ -44,7 +45,7 @@ describe('CoreServiceName → contract map (#4127)', () => {
const mapped = new Set([
'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue',
'automation', 'analytics', 'realtime', 'job', 'notification', 'ai',
- 'i18n', 'workflow',
+ 'i18n',
]);
const unmapped = CoreServiceName.options.filter((s) => !mapped.has(s));
// `ui` has a slot and a serving domain but no `IUiService` — mapping it
diff --git a/packages/spec/src/contracts/core-service-contracts.ts b/packages/spec/src/contracts/core-service-contracts.ts
index f0d9473028..6afaf338bf 100644
--- a/packages/spec/src/contracts/core-service-contracts.ts
+++ b/packages/spec/src/contracts/core-service-contracts.ts
@@ -37,7 +37,6 @@ import type { IJobService } from './job-service';
import type { INotificationService } from './notification-service';
import type { IAIService } from './ai-service';
import type { II18nService } from './i18n-service';
-import type { IWorkflowService } from './workflow-service';
import type { ISecurityService } from './security-service';
import type { IShareLinkService } from './share-link-service';
import type { IHttpServer } from './http-server';
@@ -79,7 +78,8 @@ export interface CoreServiceContracts {
ai: IAIService;
/** `service-i18n`, or the `app-plugin` in-memory fallback (#4143). */
i18n: II18nService;
- workflow: IWorkflowService;
+ // `workflow: IWorkflowService` removed with the slot (#4451, v17) — no
+ // implementation ever existed, so there was no evidenced binding here.
}
/**
diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts
index 12d6d355a6..be0b3d0187 100644
--- a/packages/spec/src/contracts/index.ts
+++ b/packages/spec/src/contracts/index.ts
@@ -31,7 +31,9 @@ export * from './job-service.js';
export * from './ai-service.js';
export * from './llm-adapter.js';
export * from './i18n-service.js';
-export * from './workflow-service.js';
+// './workflow-service.js' removed (#4451, v17): IWorkflowService had no
+// implementation and no `getService('workflow')` call site anywhere
+// (ADR-0115 Evidence 5); the slot retired with it.
// CoreServiceName → contract map (#4127). Lets a slot lookup return the slot's
// contract instead of `any`, so a call outside it is a compile error.
diff --git a/packages/spec/src/contracts/workflow-service.test.ts b/packages/spec/src/contracts/workflow-service.test.ts
deleted file mode 100644
index 9050be560b..0000000000
--- a/packages/spec/src/contracts/workflow-service.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import type { IWorkflowService, WorkflowTransition, WorkflowTransitionResult, WorkflowStatus } from './workflow-service';
-
-describe('Workflow Service Contract', () => {
- it('should allow a minimal IWorkflowService implementation with required methods', () => {
- const service: IWorkflowService = {
- transition: async (_transition) => ({ success: true, currentState: 'approved' }),
- getStatus: async (_object, _recordId) => ({
- recordId: '1', object: 'order', currentState: 'draft', availableTransitions: [],
- }),
- };
-
- expect(typeof service.transition).toBe('function');
- expect(typeof service.getStatus).toBe('function');
- });
-
- it('should allow a full implementation with optional methods', () => {
- const service: IWorkflowService = {
- transition: async () => ({ success: true }),
- getStatus: async () => ({
- recordId: '1', object: 'order', currentState: 'draft', availableTransitions: [],
- }),
- getHistory: async () => [],
- };
-
- expect(service.getHistory).toBeDefined();
- });
-
- it('should transition a record to a new state', async () => {
- const states = new Map();
- states.set('order:ord-1', 'draft');
-
- const allowedTransitions: Record = {
- draft: ['submitted'],
- submitted: ['approved', 'rejected'],
- approved: ['completed'],
- };
-
- const service: IWorkflowService = {
- transition: async (t): Promise => {
- const key = `${t.object}:${t.recordId}`;
- const current = states.get(key);
- if (!current) return { success: false, error: 'Record not found' };
-
- const allowed = allowedTransitions[current] ?? [];
- if (!allowed.includes(t.targetState)) {
- return { success: false, error: `Cannot transition from ${current} to ${t.targetState}` };
- }
-
- states.set(key, t.targetState);
- return { success: true, currentState: t.targetState };
- },
- getStatus: async (object, recordId) => {
- const key = `${object}:${recordId}`;
- const currentState = states.get(key) ?? 'unknown';
- return {
- recordId, object, currentState,
- availableTransitions: allowedTransitions[currentState] ?? [],
- };
- },
- };
-
- const result = await service.transition({
- recordId: 'ord-1',
- object: 'order',
- targetState: 'submitted',
- comment: 'Ready for review',
- });
-
- expect(result.success).toBe(true);
- expect(result.currentState).toBe('submitted');
-
- const status = await service.getStatus('order', 'ord-1');
- expect(status.currentState).toBe('submitted');
- expect(status.availableTransitions).toContain('approved');
- });
-
- it('should reject invalid transitions', async () => {
- const service: IWorkflowService = {
- transition: async (t): Promise => ({
- success: false,
- error: `Cannot transition to ${t.targetState}`,
- }),
- getStatus: async () => ({
- recordId: '1', object: 'order', currentState: 'draft',
- availableTransitions: ['submitted'],
- }),
- };
-
- const result = await service.transition({
- recordId: '1',
- object: 'order',
- targetState: 'completed',
- });
-
- expect(result.success).toBe(false);
- expect(result.error).toContain('completed');
- });
-
- it('should return transition history', async () => {
- const service: IWorkflowService = {
- transition: async () => ({ success: true }),
- getStatus: async () => ({
- recordId: '1', object: 'order', currentState: 'approved',
- availableTransitions: ['completed'],
- }),
- getHistory: async () => [
- { fromState: 'draft', toState: 'submitted', userId: 'u1', timestamp: '2025-01-01T00:00:00Z' },
- { fromState: 'submitted', toState: 'approved', userId: 'u2', comment: 'LGTM', timestamp: '2025-01-02T00:00:00Z' },
- ],
- };
-
- const history = await service.getHistory!('order', 'ord-1');
- expect(history).toHaveLength(2);
- expect(history[0].fromState).toBe('draft');
- expect(history[1].comment).toBe('LGTM');
- });
-
- it('should get workflow status with available transitions', async () => {
- const service: IWorkflowService = {
- transition: async () => ({ success: true }),
- getStatus: async (_object, _recordId): Promise => ({
- recordId: 'ord-1',
- object: 'order',
- currentState: 'submitted',
- availableTransitions: ['approved', 'rejected'],
- }),
- };
-
- const status = await service.getStatus('order', 'ord-1');
- expect(status.currentState).toBe('submitted');
- expect(status.availableTransitions).toEqual(['approved', 'rejected']);
- });
-});
diff --git a/packages/spec/src/contracts/workflow-service.ts b/packages/spec/src/contracts/workflow-service.ts
deleted file mode 100644
index d5ef04e5e3..0000000000
--- a/packages/spec/src/contracts/workflow-service.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-/**
- * IWorkflowService - Workflow State Machine Service Contract
- *
- * Defines the interface for workflow state management and approval processes
- * in ObjectStack. Concrete implementations (state machine engines, BPMN, etc.)
- * should implement this interface.
- *
- * Follows Dependency Inversion Principle - plugins depend on this interface,
- * not on concrete workflow engine implementations.
- *
- * Aligned with CoreServiceName 'workflow' in core-services.zod.ts.
- */
-
-/**
- * A state transition request
- */
-export interface WorkflowTransition {
- /** Record identifier */
- recordId: string;
- /** Object name the record belongs to */
- object: string;
- /** Target state to transition to */
- targetState: string;
- /** Optional comment for the transition */
- comment?: string;
- /** User performing the transition */
- userId?: string;
-}
-
-/**
- * Result of a transition attempt
- */
-export interface WorkflowTransitionResult {
- /** Whether the transition succeeded */
- success: boolean;
- /** The new current state (if success) */
- currentState?: string;
- /** Error or rejection reason (if failure) */
- error?: string;
-}
-
-/**
- * Status of a workflow instance
- */
-export interface WorkflowStatus {
- /** Record identifier */
- recordId: string;
- /** Object name */
- object: string;
- /** Current state */
- currentState: string;
- /** Available transitions from the current state */
- availableTransitions: string[];
-}
-
-export interface IWorkflowService {
- /**
- * Transition a record to a new workflow state
- * @param transition - Transition request details
- * @returns Transition result
- */
- transition(transition: WorkflowTransition): Promise;
-
- /**
- * Get the current workflow status of a record
- * @param object - Object name
- * @param recordId - Record identifier
- * @returns Current workflow status with available transitions
- */
- getStatus(object: string, recordId: string): Promise;
-
- /**
- * Get transition history for a record
- * @param object - Object name
- * @param recordId - Record identifier
- * @returns Array of historical transitions
- */
- getHistory?(object: string, recordId: string): Promise>;
-}
diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts
index 702e0ab806..da0f4f62c1 100644
--- a/packages/spec/src/migrations/registry.ts
+++ b/packages/spec/src/migrations/registry.ts
@@ -752,6 +752,36 @@ const step17: MigrationStep = {
+ 'door. A query still carrying the key fails to parse with the removal prescription, '
+ 'and the REST list response reports a real `total` for queries that used to send it.',
},
+ {
+ id: 'workflow-service-slot-retired',
+ surface:
+ "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / "
+ + 'discovery routes.workflow / RestApiRouteCategory workflow',
+ replacement:
+ 'the live mechanisms the slot only ever pointed at: `state_machine` validation rules '
+ + 'for record state machines, approval flow nodes on the approvals runtime (ADR-0019) '
+ + 'for approvals, lifecycle hooks + `record_change` flows (service-automation) for '
+ + 'record-triggered automation',
+ reason:
+ 'The workflow slot was declared end to end and implemented nowhere: no code in either '
+ + 'repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches '
+ + 'were plugin-dev\'s retired stub probe and the generic discovery walk), no '
+ + 'implementation of any WorkflowProtocol method ever existed, and no host ever '
+ + 'mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among '
+ + 'routes that never existed). Every part of it was ADR-0078\'s silently-inert '
+ + 'declaration: a CoreServiceName nothing filled, a contract nothing implemented, a '
+ + 'protocol nothing served, a discovery route field no builder could truthfully '
+ + 'populate. These are TS/API surfaces and a discovery RESPONSE field — never stored '
+ + 'in stack metadata, so there is no source for the chain to rewrite; consumers of the '
+ + 'deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451.',
+ acceptanceCriteria:
+ 'No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/'
+ + 'Transition types resolves; no code calls getService(\'workflow\') or reads '
+ + 'discovery `routes.workflow` / `services.workflow`; record state machines, '
+ + 'approvals and record-triggered automation go through the replacement mechanisms. '
+ + 'Discovery output on a default boot is unchanged (the slot was always reported '
+ + 'unavailable; now it is simply absent).',
+ },
],
};
diff --git a/packages/spec/src/system/core-service-provider.test.ts b/packages/spec/src/system/core-service-provider.test.ts
index fc555db15e..d979eb90fb 100644
--- a/packages/spec/src/system/core-service-provider.test.ts
+++ b/packages/spec/src/system/core-service-provider.test.ts
@@ -22,17 +22,29 @@ describe('CORE_SERVICE_PROVIDER', () => {
expect(CORE_SERVICE_PROVIDER['notification']).toBe('@objectstack/service-messaging');
});
+ // ['workflow', 'graphql'] left this list in #4451 (v17): the workflow slot
+ // was retired outright, and graphql was never a CoreServiceName — its
+ // stray entry here named a surface the dispatcher had already removed.
it('uses null — not a plausible name — where no package can be installed', () => {
- for (const slot of ['ai', 'search', 'workflow', 'graphql']) {
+ for (const slot of ['ai', 'search']) {
expect(CORE_SERVICE_PROVIDER[slot], `${slot} must name no installable package`).toBeNull();
}
});
+ it('carries no entry for retired or never-real slots (#4451)', () => {
+ for (const slot of ['workflow', 'graphql']) {
+ expect(
+ Object.prototype.hasOwnProperty.call(CORE_SERVICE_PROVIDER, slot),
+ `${slot} must have no entry`,
+ ).toBe(false);
+ }
+ });
+
// `null` covers two different situations, and only one of them means
// "nothing exists". Verified against objectstack-ai/cloud: nothing there
- // registers search/workflow/graphql, but `@objectstack/service-ai` does
- // register `ai` — it is simply `private: true`, so there is no package to
- // install and no name that belongs in an "Install X" sentence.
+ // registers search, but `@objectstack/service-ai` does register `ai` — it
+ // is simply `private: true`, so there is no package to install and no
+ // name that belongs in an "Install X" sentence.
it('still says something ships for a slot whose provider is real but uninstallable', () => {
const ai = serviceUnavailableMessage('ai');
expect(ai).not.toMatch(/No implementation ships/);
@@ -41,10 +53,8 @@ describe('CORE_SERVICE_PROVIDER', () => {
// Still not an instruction a reader could act on and fail at.
expect(ai).not.toMatch(/^Install /);
- // The genuinely-empty slots keep the plain sentence.
- for (const slot of ['search', 'workflow', 'graphql']) {
- expect(serviceUnavailableMessage(slot), slot).toMatch(/No implementation ships/);
- }
+ // The genuinely-empty slot keeps the plain sentence.
+ expect(serviceUnavailableMessage('search')).toMatch(/No implementation ships/);
});
it('scopes every named package, so the remedy is copy-pasteable', () => {
diff --git a/packages/spec/src/system/core-services.test.ts b/packages/spec/src/system/core-services.test.ts
index a25ae1a6af..a83efa52a2 100644
--- a/packages/spec/src/system/core-services.test.ts
+++ b/packages/spec/src/system/core-services.test.ts
@@ -10,11 +10,12 @@ import {
describe('CoreServiceName', () => {
it('should accept all valid service names', () => {
+ // ('workflow' retired with its slot, #4451 v17.)
const services = [
'metadata', 'data', 'auth',
'file-storage', 'search', 'cache', 'queue',
'automation', 'analytics', 'realtime',
- 'job', 'notification', 'ai', 'i18n', 'ui', 'workflow',
+ 'job', 'notification', 'ai', 'i18n', 'ui',
];
services.forEach((service) => {
@@ -67,7 +68,8 @@ describe('ServiceRequirementDef', () => {
expect(ServiceRequirementDef.notification).toBe('optional');
expect(ServiceRequirementDef.ai).toBe('optional');
expect(ServiceRequirementDef.ui).toBe('optional');
- expect(ServiceRequirementDef.workflow).toBe('optional');
+ // `workflow` retired with its slot (#4451, v17).
+ expect(ServiceRequirementDef).not.toHaveProperty('workflow');
});
});
diff --git a/packages/spec/src/system/core-services.zod.ts b/packages/spec/src/system/core-services.zod.ts
index ae7004c160..3ff7aa571a 100644
--- a/packages/spec/src/system/core-services.zod.ts
+++ b/packages/spec/src/system/core-services.zod.ts
@@ -38,7 +38,13 @@ export const CoreServiceName = z.enum([
'ai', // AI Engine (NLQ, Chat, Suggest, Insights)
'i18n', // Internationalization Service
'ui', // UI Metadata Service (View CRUD)
- 'workflow', // Workflow State Machine Engine
+ // `workflow` (Workflow State Machine Engine) retired in #4451 (v17):
+ // nothing ever registered or resolved the slot (ADR-0115 Evidence 5), no
+ // provider ships in either repository, and the capability is live elsewhere
+ // — `state_machine` validation rules, approval flow nodes (ADR-0019),
+ // lifecycle hooks + `record_change` flows (service-automation).
+ // (Backticks, not quotes: scripts/check-service-providers.mjs reads every
+ // quoted token in this block as an enum member, comments included.)
]);
export type CoreServiceName = z.infer;
@@ -94,10 +100,9 @@ export const CORE_SERVICE_PROVIDER: Readonly> = {
// different situations — see REMEDY_DETAIL, which is how the second one still
// gets an accurate message.
//
- // Nothing provides the slot at all: `search`, `workflow` (no consumer
- // either — ADR-0115 Evidence 5) and `graphql` (a surface with no provider).
- // Verified across BOTH repositories: nothing in `objectstack-ai/cloud`
- // registers them.
+ // Nothing provides the slot at all: `search` (no consumer either —
+ // ADR-0115 Evidence 5). Verified across BOTH repositories: nothing in
+ // `objectstack-ai/cloud` registers it.
//
// A provider exists but cannot be installed: `ai`. `@objectstack/service-ai`
// registers this slot in `objectstack-ai/cloud` and is `private: true`, so
@@ -105,10 +110,16 @@ export const CORE_SERVICE_PROVIDER: Readonly> = {
// exact failure this table was written to end. It carries a REMEDY_DETAIL
// sentence instead; a bare `null` here would tell a Cloud/Enterprise
// deployment that nothing ships, which is false.
+ //
+ // Two entries left in #4451 (v17). `graphql` was never a `CoreServiceName`
+ // — it existed only here and in metadata-protocol's discovery table, naming
+ // a `/graphql` surface the dispatcher had already removed as out of the
+ // product plan (#2462 follow-on); this table's guard
+ // (`scripts/check-service-providers.mjs`) only checks that every SLOT has an
+ // entry, never that every entry is a slot, so the stray sat unchallenged.
+ // `workflow` retired with its slot — see the CoreServiceName note above.
'ai': null,
'search': null,
- 'workflow': null,
- 'graphql': null,
} as const;
/**
@@ -205,7 +216,6 @@ export const ServiceRequirementDef = {
notification: 'optional',
ai: 'optional',
ui: 'optional',
- workflow: 'optional',
} as const;
// ==========================================