diff --git a/.changeset/19543-list-doors-3-4.md b/.changeset/19543-list-doors-3-4.md new file mode 100644 index 00000000000..ba2b6ca0b8f --- /dev/null +++ b/.changeset/19543-list-doors-3-4.md @@ -0,0 +1,65 @@ +--- +'@objectstack/spec': minor +'@objectstack/client': minor +'@objectstack/runtime': minor +--- + +feat!: retire the `GET /api/v1/automation` flow list in favour of `GET /api/v1/meta/flow`; `ListAiConversationsResponse` declares `hasMore` (#19543) + +**BREAKING** — two sibling list doors that declared paging nobody honoured. + +**The flow list is retired, with no alias and no transition window** (maintainer +ruling: 「退役,统一走 /meta/flow」). Its contract described a capability no build +ever delivered: the request declared `status`, `type`, `limit` (default 50) and +`cursor`, and the route read none of them; the response declared `FlowSummary` +rows with `total`, `nextCursor` and `hasMore`, and the route answered bare flow +names beside a literal `hasMore: false`. Measured before removal on the main branch +of this repository and cloud, and on objectui at its pinned commit and at main: +zero callers of the route or of `client.automation.list` outside their own tests, +while the Console flow-runs page and the Setup packaged-automation page already +read `GET /api/v1/meta/flow`. + +FROM → TO, per surface: + +- `GET /api/v1/automation` (and its environment-scoped twin) → no longer mounted + for `GET`. `POST /api/v1/automation` (create a flow) still lives at that path, so + on the default Hono host a `GET` there answers the host's standard method + mismatch — `405 METHOD_NOT_ALLOWED` with `Allow: POST` — the same answer any + POST-only path gets. A transport that forwards every automation path to the + dispatcher (the `@objectstack/hono` catch-all) is told the domain does not handle + it and answers its own not-found `404`. Fix: read `GET /api/v1/meta/flow`; + flows are metadata (ADR-0106), and it answers full definitions, so map each item + to its `name` if you only need names. Per-flow runtime enablement and trigger + binding is `GET /api/v1/automation/_status`, unchanged. +- `client.automation.list` (`@objectstack/client`) → removed; calling it is a + compile error. Fix: `client.meta.getItems('flow')`, or + `client.automation.getRuntimeStatus()` for the enabled/bound state. +- `ListFlowsRequestSchema`, `ListFlowsResponseSchema`, `FlowSummarySchema` and the + types `ListFlowsRequest`, `ListFlowsRequestParsed`, `ListFlowsResponse`, + `ListFlowsResponseParsed`, `FlowSummary` (`@objectstack/spec/api`) → removed, + no replacement export (TS2305 on import). Fix: delete the import; the flow + definition type is `Flow` from `@objectstack/spec/automation`. +- `AutomationApiContracts.listFlows` → removed; the map has eight entries, none of + them a `GET` at the bare path. Every other automation route is unchanged. + +**`ListAiConversationsResponseSchema` gains a required `hasMore`** (the spec half +of the same card; the server half is objectstack-ai/cloud#2426). The list is +declared **newest first** and pages by keyset: `cursor` is the `id` of the last +conversation the caller already holds, and `hasMore` says whether another page +follows. `hasMore` is required rather than optional so a server that does not +compute it is off-contract instead of silently spec-valid; no `nextCursor` is +declared, because the next cursor is the last conversation's id, already on the +page. Who notices: code that constructs a `ListAiConversationsResponse` must now +set `hasMore`, and a response parsed with the schema is refused without it. +`client.ai.conversations.list()` is unchanged — it still resolves to the +conversation array. + +Breaking ships as `minor` per the launch-window convention +(`scripts/check-changeset-no-major.mjs`). + +**Clause-②: yes (narrowing)** — the conversation list's response surface gains a +declared `hasMore`; a route, an SDK method, three published schemas with their five +types and a contract entry are removed, and a conversation-list response without +`hasMore` is now refused. + + diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index e900c4f6c57..0f75bddb594 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -393,7 +393,9 @@ await client.ai.models(); // plan-filtered picker list (ADR-0028) // Conversations — all six routes, scoped to the authenticated user server-side. const conv = await client.ai.conversations.create({ title: 'Q3 pipeline' }); -await client.ai.conversations.list({ limit: 20 }); +const page = await client.ai.conversations.list({ limit: 20 }); // newest first +// The next page: the cursor is the id of the last conversation you hold. +await client.ai.conversations.list({ limit: 20, cursor: page.at(-1)?.id }); await client.ai.conversations.get(conv.id); await client.ai.conversations.addMessage(conv.id, { role: 'user', content: 'hi' }); await client.ai.conversations.update(conv.id, { title: 'Renamed' }); diff --git a/content/docs/api/plugin-endpoints.mdx b/content/docs/api/plugin-endpoints.mdx index e653c572e29..c99d452a0ab 100644 --- a/content/docs/api/plugin-endpoints.mdx +++ b/content/docs/api/plugin-endpoints.mdx @@ -50,7 +50,9 @@ Approve/reject were never workflow routes (ADR-0019): approval is a flow node, a |:-------|:---------|:------------| | POST | `/automation/:name/trigger` | Trigger an automation flow by name (legacy alias: `/automation/trigger/:name`). Failures answer real status codes, not a `200` wrapping an inner failure: **404** unknown flow, **409** `FLOW_DISABLED`, **422** `FLOW_NO_START_NODE`, **422** `FLOW_INPUT_SCHEMA_INVALID`, **400** `FLOW_FAILED` for a run that ran and was rejected — see [Run a flow via API](/docs/automation/flows#run-a-flow-via-api) | -The automation dispatcher also exposes flow CRUD (`GET`/`POST /automation`, `GET`/`PUT`/`DELETE /automation/:name`) and run observability/resume routes — see [Durable pause & resume](/docs/automation/flows#durable-pause--resume-adr-0019). +The automation dispatcher also exposes flow CRUD (`POST /automation`, `GET`/`PUT`/`DELETE /automation/:name`) and run observability/resume routes — see [Durable pause & resume](/docs/automation/flows#durable-pause--resume-adr-0019). + +To **list** flows, read the metadata plane: `GET /meta/flow` (`client.meta.getItems('flow')`) — flows are metadata (ADR-0106). There is no `GET /automation` list route; it was retired, and a `GET` to that path gets the host's method-mismatch answer (`405` with `Allow: POST` on the default server), because only `POST` lives there. Per-flow runtime enablement and trigger binding is `GET /automation/_status` (`client.automation.getRuntimeStatus()`). ### Views (`/ui`) — Plugin Required @@ -109,7 +111,7 @@ These are the routes `service-ai` mounts, and the SDK method that reaches each: | GET | `/ai/models` | `ai.models` | Models this environment offers (ADR-0028) | | GET | `/ai/status` | — | Active adapter provenance (console diagnostics) | | GET | `/ai/effective-model` | — | Resolved model ids and their source (console diagnostics) | -| POST / GET | `/ai/conversations` | `ai.conversations.create` / `.list` | Create / list conversations | +| POST / GET | `/ai/conversations` | `ai.conversations.create` / `.list` | Create / list conversations — the list is newest first and pages by keyset: send the `id` of the last conversation you hold as `cursor`, and read `hasMore` to learn whether another page follows | | GET / PATCH / DELETE | `/ai/conversations/:id` | `ai.conversations.get` / `.update` / `.delete` | Read / update / delete | | POST | `/ai/conversations/:id/messages` | `ai.conversations.addMessage` | Append a message | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 8864e444ede..733bfa2a15e 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -18,9 +18,14 @@ The wire paths the platform serves: the dispatcher mounts this door at its (`automation-api-contract-mounts.test.ts`) holds every `path` in `AutomationApiContracts` to that mount table. +The flow LIST is not on this door. Flows are metadata (ADR-0106), and the +governed read of them is `GET /api/v1/meta/flow` (`client.meta.getItems`); +the former `GET /api/v1/automation` list route, its request/response schemas +and `client.automation.list` are retired (ADR-0087 semantic entry +`automation-flow-list-route-retired`). + **Endpoints** ``` -GET /api/v1/automation — List flows GET /api/v1/automation/:name — Get flow POST /api/v1/automation — Create flow PUT /api/v1/automation/:name — Update flow @@ -38,8 +43,8 @@ GET /api/v1/automation/:name/runs/:runId — Get single execution run ## TypeScript Usage ```typescript -import { AutomationApiErrorCode, AutomationFlowPathParamsSchema, AutomationRunPathParamsSchema, CreateFlowRequestSchema, CreateFlowResponseSchema, DeleteFlowRequestSchema, DeleteFlowResponseSchema, FlowSummarySchema, GetFlowRequestSchema, GetFlowResponseSchema, GetRunRequestSchema, GetRunResponseSchema, ListFlowsRequestSchema, ListFlowsResponseSchema, ListRunsRequestSchema, ListRunsResponseSchema, ResumeFailureDetailsSchema, ToggleFlowRequestSchema, ToggleFlowResponseSchema, TriggerFlowRequestSchema, TriggerFlowResponseSchema, UpdateFlowRequestSchema, UpdateFlowResponseSchema } from '@objectstack/spec/api'; -import type { AutomationApiErrorCode, AutomationFlowPathParams, AutomationRunPathParams, CreateFlowRequest, CreateFlowResponse, DeleteFlowRequest, DeleteFlowResponse, FlowSummary, GetFlowRequest, GetFlowResponse, GetRunRequest, GetRunResponse, ListFlowsRequest, ListFlowsResponse, ListRunsRequest, ListRunsResponse, ResumeFailureDetails, ToggleFlowRequest, ToggleFlowResponse, TriggerFlowRequest, TriggerFlowResponse, UpdateFlowRequest, UpdateFlowResponse } from '@objectstack/spec/api'; +import { AutomationApiErrorCode, AutomationFlowPathParamsSchema, AutomationRunPathParamsSchema, CreateFlowRequestSchema, CreateFlowResponseSchema, DeleteFlowRequestSchema, DeleteFlowResponseSchema, GetFlowRequestSchema, GetFlowResponseSchema, GetRunRequestSchema, GetRunResponseSchema, ListRunsRequestSchema, ListRunsResponseSchema, ResumeFailureDetailsSchema, ToggleFlowRequestSchema, ToggleFlowResponseSchema, TriggerFlowRequestSchema, TriggerFlowResponseSchema, UpdateFlowRequestSchema, UpdateFlowResponseSchema } from '@objectstack/spec/api'; +import type { AutomationApiErrorCode, AutomationFlowPathParams, AutomationRunPathParams, CreateFlowRequest, CreateFlowResponse, DeleteFlowRequest, DeleteFlowResponse, GetFlowRequest, GetFlowResponse, GetRunRequest, GetRunResponse, ListRunsRequest, ListRunsResponse, ResumeFailureDetails, ToggleFlowRequest, ToggleFlowResponse, TriggerFlowRequest, TriggerFlowResponse, UpdateFlowRequest, UpdateFlowResponse } from '@objectstack/spec/api'; // Validate data const result = AutomationApiErrorCode.parse(data); @@ -297,24 +302,6 @@ const result = AutomationApiErrorCode.parse(data); | **deleted** | `boolean` | ✅ | Whether the flow was deleted | ---- - -## FlowSummary - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Flow machine name | -| **label** | `string` | ✅ | Flow display label | -| **type** | `string` | ✅ | Flow type | -| **status** | `string` | ✅ | Flow deployment status | -| **version** | `integer` | ✅ | Flow version number | -| **enabled** | `boolean` | ✅ | Whether the flow is enabled for execution | -| **nodeCount** | `integer` | optional | Number of nodes in the flow | -| **lastRunAt** | `string` | optional | Last execution timestamp | - - --- ## GetFlowRequest @@ -459,66 +446,6 @@ const result = AutomationApiErrorCode.parse(data); | **tenantId** | `string` | optional | Tenant ID for multi-tenant isolation | ---- - -## ListFlowsRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional | Filter by flow status | -| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | optional | Filter by flow type | -| **limit** | `integer` | optional (default: `50`) | Maximum number of flows to return | -| **cursor** | `string` | optional | Cursor for pagination | - - ---- - -## ListFlowsResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ flows: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | - -### Nested Shape: `ListFlowsResponse.error` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | -| **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | -| **refusal** | `true` | optional | Producer-declared: the 5xx this envelope carries is a deliberate refusal whose `message` is authored for the caller, so a boundary that reads the declaration keeps it verbatim (until the withhold arms read it, a declared refusal is still withheld). Absent (the default) on a declared fault, whose `message` is withheld from the body and logged for the operator; redundant on a 4xx. Presence is the declaration — `true` is the only value. | -| **category** | `string` | optional | Error category (e.g. validation, authorization) | -| **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | -| **details** | `any` | optional | Additional error context (e.g. field validation errors) | -| **requestId** | `string` | optional | Request ID for tracking | - -### Nested Shape: `ListFlowsResponse.meta` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **timestamp** | `string` | ✅ | | -| **duration** | `integer` | optional | Server-side processing duration in milliseconds | -| **requestId** | `string` | optional | | -| **traceId** | `string` | optional | | - -### Nested Shape: `ListFlowsResponse.data` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **flows** | `{ name: string; label: string; type: string; status: string; … }[]` | ✅ | Flow summaries | -| **total** | `integer` | optional | Total matching flows | -| **nextCursor** | `string` | optional | Cursor for the next page | -| **hasMore** | `boolean` | ✅ | Whether more flows are available | - - --- ## ListRunsRequest diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index e8afc33c54f..0cf97a05eb0 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1996,7 +1996,7 @@ Install package response | :--- | :--- | :--- | :--- | | **agentId** | `string` | optional | Filter by agent | | **limit** | `integer` | optional | Maximum conversations to return | -| **cursor** | `string` | optional | Pagination cursor | +| **cursor** | `string` | optional | The `id` of the last conversation on the previous page. The next page starts with the conversation created immediately before it, continuing newest first. Omit it to read the first page. An id that names no conversation of the caller is refused rather than read as the start of the list. | --- @@ -2007,7 +2007,8 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **conversations** | `{ id: string; title?: string; agentId?: string; userId?: string; … }[]` | ✅ | Matching conversations | +| **conversations** | `{ id: string; title?: string; agentId?: string; userId?: string; … }[]` | ✅ | The caller's conversations, newest first — ordered by creation time, then `id`, both descending | +| **hasMore** | `boolean` | ✅ | Whether at least one more conversation follows this page. When `true`, send the `id` of the last conversation in `conversations` as `cursor` to read the next page. | ### Nested Shape: `ListAiConversationsResponse.conversations[number]` diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 2ae2f94cc89..c1be0f5264b 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1525 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 432 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 429 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 159 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1525** | 14 protocol modules | +| **Total** | **196** | **1522** | 14 protocol modules | --- @@ -62,7 +62,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 432 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 429 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -71,7 +71,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` | | [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` | | [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` | -| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowSummary`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListFlowsRequest`, `ListFlowsResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | +| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | | [`batch.zod.ts`](/docs/references/api/batch) | `BatchConfig`, `BatchOperationResult`, `BatchOperationType`, `BatchOptions`, `BatchRecord`, `BatchUpdateRequest`, `BatchUpdateResponse`, `CrossObjectBatchDroppedFields`, `CrossObjectBatchOperation`, `CrossObjectBatchRequest`, `CrossObjectBatchResponse`, `DeleteManyRequest`, `UpdateManyRecord`, `UpdateManyRequest` | | [`contract.zod.ts`](/docs/references/api/contract) | `ApiError`, `BaseResponse`, `BatchLoadingStrategy`, `BulkRequest`, `BulkResponse`, `CreateRequest`, `DataLoaderConfig`, `DeleteResponse`, `ExportRequest`, `IdRequest`, `ListRecordResponse`, `ModificationResult`, `QueryOptimizationConfig`, `RecordData`, `SingleRecordResponse`, `UpdateRequest` | | [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `EnvironmentType`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 70b6d05f511..215c2d1d4a7 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 78 | -| `api/` | 435 | +| `api/` | 432 | | `identity/` | 32 | | `integration/` | 8 | | `kernel/` | 247 | diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 574bff06530..d1a14bb9a0c 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -853,7 +853,7 @@ }, "steps": [ "boot showcase on the platform-default auth posture; do NOT sign in for the probe half", - "fire unauthenticated requests, one per mounted surface family: GET /api/v1/data/showcase_private_note (data), GET /api/v1/meta (metadata), POST /api/v1/actions/showcase_task/showcase_mark_done/anon-probe-id (dispatcher actions — deliberately a NONEXISTENT record id), GET /api/v1/automation (dispatcher automation), POST /api/v1/batch {\"operations\": []} (batch), GET /api/v1/security/explain (security-explain)", + "fire unauthenticated requests, one per mounted surface family: GET /api/v1/data/showcase_private_note (data), GET /api/v1/meta (metadata), POST /api/v1/actions/showcase_task/showcase_mark_done/anon-probe-id (dispatcher actions — deliberately a NONEXISTENT record id), GET /api/v1/automation/_status (dispatcher automation — the flow-inventory read; the GET /api/v1/automation flow list is retired, #19543), POST /api/v1/batch {\"operations\": []} (batch), GET /api/v1/security/explain (security-explain)", "capture status + full body per surface", "classify each 401 body into exactly ONE of the two declared envelope families (rest-flat vs dispatcher-wrapper, #5632) — no tolerant cross-family reads", "sign in as a member and repeat the data + meta reads to prove the gate keys on anonymity, not on the routes", @@ -898,7 +898,7 @@ } ], "negative": [ - "the destructive automation case must be denied too: anonymous DELETE /api/v1/automation/showcase_reassign_wizard answers 401 and the flow remains registered afterwards (verify by an authed GET /api/v1/automation listing it) — a 200 {deleted:true} is the exact #5519 regression" + "the destructive automation case must be denied too: anonymous DELETE /api/v1/automation/showcase_reassign_wizard answers 401 and the flow remains registered afterwards (verify by an authed GET /api/v1/automation/showcase_reassign_wizard answering 200 — the GET /api/v1/automation flow list is retired, #19543) — a 200 {deleted:true} is the exact #5519 regression" ], "variants": [ "data (/api/v1/data)", diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 91feb61557f..7e0abd0c230 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1219,18 +1219,24 @@ describe('FilterBuilder enhancements', () => { // ========================================== describe('ObjectStackClient.automation', () => { - it('should list flows', async () => { - const { client, fetchMock } = createMockClient({ - success: true, - data: { flows: ['flow_a', 'flow_b'], total: 2, hasMore: false }, - }); + // [#19543, door ④] `automation.list` is retired with `GET /api/v1/automation`; + // the flow list is `meta.getItems('flow')` (`GET /api/v1/meta/flow`). + it('declares no `list` — the retired flow-list door has no SDK method', () => { + const { client, fetchMock } = createMockClient({ success: true, data: {} }); + expect('list' in client.automation).toBe(false); + // @ts-expect-error `automation.list` was removed; calling it is a compile error. + void client.automation.list; + // Anti-vacuity: the namespace itself is live and its sibling reads survive. + expect(typeof client.automation.get).toBe('function'); + expect(typeof client.automation.getRuntimeStatus).toBe('function'); + expect(fetchMock).not.toHaveBeenCalled(); + }); - const result = await client.automation.list(); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/automation', - expect.any(Object), - ); - expect(result.flows).toEqual(['flow_a', 'flow_b']); + it('the replacement read — meta.getItems(\'flow\') — targets GET /api/v1/meta/flow', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { type: 'flow', items: [] } }); + await client.meta.getItems('flow'); + expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/meta/flow'); + expect(fetchMock.mock.calls[0][1]?.method ?? 'GET').toBe('GET'); }); it('should get a flow by name', async () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4f334d3148a..67a513b7418 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5414,14 +5414,12 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, - /** - * List all registered automation flows - */ - list: async (): Promise<{ flows: string[]; total: number; hasMore: boolean }> => { - const route = this.getRoute('automation'); - const res = await this.fetch(`${this.baseUrl}${route}`); - return this.unwrapResponse(res); - }, + // [#19543, door ④] No `list` here: `GET /api/v1/automation` is + // retired. Flows are metadata (ADR-0106) — list them with + // `client.meta.getItems('flow')`, which answers the definitions rather + // than bare names; per-flow enablement is `getRuntimeStatus`. The method + // was removed rather than re-pointed so a caller learns at compile time, + // not from a 404. /** * Get a flow definition by name diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index bdf7f9a4081..d9981eb47f3 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -25,7 +25,7 @@ // dispatcher domain files. // // The population comes from `packages/rest/src/rest-route-ledger.ts` (83 rows -// / 18 families) and `packages/runtime/src/route-ledger.ts` (82 rows / 21 +// / 18 families) and `packages/runtime/src/route-ledger.ts` (81 rows / 21 // domains) because those two are enumerated from a RUNNING server and guarded // in both directions by their own conformance tests — so a new family or // domain cannot be silently absent from them, and therefore cannot be silently diff --git a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts index d2012706721..1a87afbde1e 100644 --- a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts +++ b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts @@ -59,8 +59,9 @@ * Ledger-sourced population keys with no classifying matrix row. * * MEASURED 2026-08-31 against `rest-route-ledger.ts` (94 rows / 19 families) - * and `route-ledger.ts` (80 rows / 21 domains — 82 since the two operator - * run-lifecycle rows landed, both under the already-classified `/automation` + * and `route-ledger.ts` (80 rows / 21 domains — 82 after the two operator + * run-lifecycle rows landed, and 81 since #19543 retired the `GET /automation` + * flow-list row, all three moves under the already-classified `/automation` * domain, so the key arithmetic below is unmoved): 40 keys minted, 6 classified * by rows that already pin the same surface through the probe table, 34 here. * diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index f96645669b7..32397f74478 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -106,7 +106,7 @@ // `RestServer.getRoutes()` on a booted server and guarded per route by // `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars; // this table reaches 1. -// `packages/runtime/src/route-ledger.ts`: 82 rows over 21 domains. Its +// `packages/runtime/src/route-ledger.ts`: 81 rows over 21 domains. Its // machine contract is DOMAIN-level, by live registry introspection // (`domainRegistry.list()`), the per-route rows being documentation. It // covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all @@ -358,11 +358,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // Both carry `domain: '/automation'`, an EXISTING key, so `reachable` moves // with `population`, `blindSpot` stays 0 and `keys` stays 21 — a population // that grows inside an already-classified domain mints nothing new. - population: 82, - reachable: 82, + // [#19543] 82 -> 81: the `GET /automation` flow-list row left with its + // route (door ④ — the list is `GET /meta/flow`). It carried + // `domain: '/automation'`, a key other rows still carry, so `reachable` + // moves with `population`, `blindSpot` stays 0 and `keys` stays 21. + population: 81, + reachable: 81, blindSpot: 0, populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)', - controls: { "route: '": 82, "domain: '": 82, RouteLedgerEntry: 2 }, + controls: { "route: '": 81, "domain: '": 81, RouteLedgerEntry: 2 }, note: 'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' + '(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' + diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index 1aad1c25a6f..ebbe1dcd211 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -453,9 +453,13 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => expect(r.status, 'anonymous flow trigger must be 401').toBe(401); }); - it('anonymous GET /automation is denied (401) — the flow inventory stays private', async () => { - const r = await anon('GET', '/automation'); - expect(r.status, 'anonymous flow listing must be 401').toBe(401); + // [#19543] Probed at `GET /automation/_status` — the flow inventory's + // surviving read. Door ④ retired the `GET /automation` flow list (the list is + // `GET /meta/flow`), so that path no longer reaches this domain at all: the + // host answers its method mismatch before any gate runs. + it('anonymous GET /automation/_status is denied (401) — the flow inventory stays private', async () => { + const r = await anon('GET', '/automation/_status'); + expect(r.status, 'anonymous flow-inventory read must be 401').toBe(401); }); it('anonymous DELETE /automation/:name is denied (401) — the destructive one', async () => { @@ -464,8 +468,8 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => }); it('an authenticated caller reaches the domain, which answers 501 — not 401', async () => { - const r = await stack.apiAs(memberToken, 'GET', '/automation'); - expect(r.status, 'authenticated flow listing must clear the auth gate').not.toBe(401); + const r = await stack.apiAs(memberToken, 'GET', '/automation/_status'); + expect(r.status, 'authenticated flow-inventory read must clear the auth gate').not.toBe(401); // The domain's OWN answer on a stack with no automation service. Asserting // it (rather than only `.not.toBe(401)`) is what proves the anonymous 401 // above is produced by the gate and not by the domain: drop the gate and @@ -523,7 +527,7 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => const dispatcher = await Promise.all([ anon('POST', ACTION, { params: {} }).then((r) => r.json()), anon('POST', `/automation/${FLOW}/trigger`, {}).then((r) => r.json()), - anon('GET', '/automation').then((r) => r.json()), + anon('GET', '/automation/_status').then((r) => r.json()), anon('DELETE', `/automation/${FLOW}`).then((r) => r.json()), anon('GET', '/packages').then((r) => r.json()), anon('POST', '/packages/anon-probe-pkg/discard-drafts', {}).then((r) => r.json()), @@ -613,7 +617,7 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => { seam: `GET ${OBJ}`, owner: '@objectstack/rest enforceAuth', family: 'rest-flat', call: () => anon('GET', OBJ) }, { seam: 'POST /actions/:object/:action/:id', owner: 'runtime domains/actions.ts', family: 'dispatcher-wrapper', call: () => anon('POST', ACTION, { params: {} }) }, { seam: 'POST /automation/:name/trigger', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('POST', `/automation/${FLOW}/trigger`, {}) }, - { seam: 'GET /automation', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('GET', '/automation') }, + { seam: 'GET /automation/_status', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('GET', '/automation/_status') }, { seam: 'DELETE /automation/:name', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('DELETE', `/automation/${FLOW}`) }, ]; @@ -644,7 +648,7 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => it('`ANONYMOUS_DENY_BODY` is the REST seam body, and NOT the dispatcher one (#5632)', async () => { const flat = await anon('GET', '/meta').then((r) => r.json()); - const wrapped = await anon('GET', '/automation').then((r) => r.json()); + const wrapped = await anon('GET', '/automation/_status').then((r) => r.json()); // The narrowed docstring's positive claim, on the wire: the exported // constant IS what the REST seam writes, whole. diff --git a/packages/runtime/src/dispatcher-plugin.anonymous-gate.integration.test.ts b/packages/runtime/src/dispatcher-plugin.anonymous-gate.integration.test.ts index 264d6798787..5a77177cb96 100644 --- a/packages/runtime/src/dispatcher-plugin.anonymous-gate.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.anonymous-gate.integration.test.ts @@ -40,6 +40,7 @@ const executeAction = vi.fn(async () => ({ ok: true, wrote: 'system-elevated' }) const automationExecute = vi.fn(async () => ({ success: true, status: 'paused', runId: 'run_1' })); const unregisterFlow = vi.fn(); const listFlows = vi.fn(async () => ['crm_escalation_flow']); +const getFlowRuntimeStates = vi.fn(() => [{ name: 'crm_escalation_flow', enabled: true, bound: true }]); /** One `script` action, declared on the object and carrying NO `requiredPermissions`. */ const scriptAction = { @@ -78,6 +79,7 @@ function servicesPlugin(): Plugin { execute: automationExecute, unregisterFlow, listFlows, + getFlowRuntimeStates, registerFlow: () => { /* unused */ }, handlerReady: true, }); @@ -166,14 +168,57 @@ describe('#5519 — the mounted /actions and /automation routes deny anonymous c expect(automationExecute).not.toHaveBeenCalled(); }, 60_000); - it('anonymous GET /api/v1/automation → 401, the flow inventory stays private', async () => { - listFlows.mockClear(); - const res = await fetch(`${baseUrl}/api/v1/automation`); + it('anonymous GET /api/v1/automation/_status → 401, the flow inventory stays private', async () => { + getFlowRuntimeStates.mockClear(); + const res = await fetch(`${baseUrl}/api/v1/automation/_status`); expect(res.status).toBe(401); + expect(getFlowRuntimeStates).not.toHaveBeenCalled(); + }, 60_000); + + // ── #19543 door ④: the flow list is RETIRED, and on the wire that means ── + // `GET` is not mounted at the path at all. Flows are metadata (ADR-0106); + // the list is `GET /api/v1/meta/flow`. `POST` (createFlow) still lives at + // the same path, so the host answers its standard METHOD MISMATCH — `405` + // with an accurate `Allow` — exactly as it does for any path where only + // another verb was ever registered. No retirement-specific text, code or + // hint reaches the wire. + + it('[#19543] GET /api/v1/automation answers the host\'s standard 405 + Allow: POST — anonymous AND with a session', async () => { + listFlows.mockClear(); + // The control: a path of this domain where GET was NEVER registered + // and POST is (`/:name/toggle`). Same host, same answer shape. + const control = await fetch(`${baseUrl}/api/v1/automation/crm_escalation_flow/toggle`); + const controlBody: any = await control.json(); + expect(control.status).toBe(405); + + for (const headers of [{}, { [SESSION_HEADER]: '1' }]) { + const res = await fetch(`${baseUrl}/api/v1/automation`, { headers }); + expect(res.status).toBe(405); + expect(res.headers.get('allow')).toBe('POST'); + const body: any = await res.json(); + expect(body.success).toBe(false); + expect(body.error.code).toBe('METHOD_NOT_ALLOWED'); + expect(body.error.details).toEqual({ method: 'GET', path: '/api/v1/automation', allowed: ['POST'] }); + // Byte-identical to the never-a-GET control once the echoed path is + // factored out — the retired route left nothing of its own behind. + const echoless = (b: unknown, path: string) => JSON.stringify(b).split(path).join('PATH'); + expect(echoless(body, '/api/v1/automation')) + .toBe(echoless(controlBody, '/api/v1/automation/crm_escalation_flow/toggle')); + } + // Answered by the host before any dispatch: the engine's name + // enumeration is unreachable over HTTP. expect(listFlows).not.toHaveBeenCalled(); }, 60_000); + it('[#19543] POST /api/v1/automation (createFlow) is still MOUNTED at the same path — the retirement is one verb', async () => { + // Anonymous, so the domain floor answers 401 — an answer only a mounted + // route can give. A 404 or 405 here would mean the retirement took the + // create door with it. + const res = await post('/automation', { name: 'injected_flow' }); + expect(res.status).toBe(401); + }, 60_000); + it('anonymous DELETE /api/v1/automation/:name → 401 — the destructive one', async () => { unregisterFlow.mockClear(); const res = await fetch(`${baseUrl}/api/v1/automation/crm_escalation_flow`, { method: 'DELETE' }); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 3f450870862..acbf6f9366a 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1463,15 +1463,14 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // `automation` service (which lives on the project kernel, // not the host kernel, in ObjectOS multi-tenant mode). const registerAutomationRoutes = (base: string) => { - server!.get(`${base}/automation`, async (req: any, res: any) => { - try { - const result = await dispatcher.dispatch('GET', '/automation', undefined, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - + // [#19543, door ④] No `GET ${base}/automation`: the flow-list + // route is RETIRED (「退役,统一走 /meta/flow」), so GET is not + // mounted at this path at all and the host gives its standard + // unmatched answer — on the Hono host a `405` with + // `Allow: POST`, because the POST below (createFlow) keeps the + // path; the same answer any POST-only path gets, with no + // residual refusal of its own. Flows are metadata (ADR-0106); + // the list is `GET /api/v1/meta/flow`. server!.post(`${base}/automation`, async (req: any, res: any) => { try { const result = await dispatcher.dispatch('POST', '/automation', req.body, req.query, { request: req }); diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index fe7814bb378..a6701a33bd3 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -645,11 +645,34 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { */ const auth = { api: { getSession: async () => ({ user: { id: 'u_test' } }) } }; - it('GET /automation lists flows via the automation service', async () => { - const automation = { listFlows: vi.fn().mockResolvedValue(['flow-a', 'flow-b']) }; - const result = await makeDispatcher({ automation, auth }).dispatch('GET', '/automation', undefined, {}, {} as any); - expect(result.response?.status).toBe(200); - expect(result.response?.body?.data?.total).toBe(2); + it('[#19543] GET /automation is RETIRED — the domain declines it, and the flow-name enumeration is never called', async () => { + // Door ④: the flow list is `GET /meta/flow`. The domain keeps no branch + // for `GET /`, so the real `dispatch()` hands back `handled: false` — + // the ownership signal a transport renders as its own unmatched answer + // (the dispatcher plugin never mounts the path; a catch-all adapter + // answers its enveloped 404) — even though the service still offers + // `listFlows` (an engine method, not a route). + const listFlows = vi.fn().mockResolvedValue(['flow-a', 'flow-b']); + const getFlowRuntimeStates = vi.fn().mockReturnValue([{ name: 'flow-a', enabled: true, bound: true }]); + const automation = { listFlows, getFlowRuntimeStates, handlerReady: true }; + const dispatcher = makeDispatcher({ automation, auth }); + + const result = await dispatcher.dispatch('GET', '/automation', undefined, {}, {} as any); + expect(result).toEqual({ handled: false }); + expect(listFlows).not.toHaveBeenCalled(); + + // …exactly what the domain answers for a sub-path it never had: no + // retirement-specific refusal, code or hint of its own. + const never = await dispatcher.dispatch('GET', '/automation/zz/never/mounted', undefined, {}, {} as any); + expect(never).toEqual(result); + + // Anti-vacuity: the same dispatcher, identity and service DO serve a + // surviving read of this domain — the 404 above is the retirement, not a + // dead domain or a refused caller. + const status = await dispatcher.dispatch('GET', '/automation/_status', undefined, {}, {} as any); + expect(status.response?.status).toBe(200); + expect(status.response?.body?.data?.total).toBe(1); + expect(getFlowRuntimeStates).toHaveBeenCalledTimes(1); }); it('GET /automation/actions keeps its guard position before the /:name catch-all and applies filters', async () => { @@ -669,7 +692,7 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { }); it('falls through unhandled when no automation service is registered', async () => { - const result = await makeDispatcher().dispatch('GET', '/automation', undefined, {}, {} as any); + const result = await makeDispatcher().dispatch('GET', '/automation/_status', undefined, {}, {} as any); expect(result.response?.status ?? 404).not.toBe(200); }); diff --git a/packages/runtime/src/domains/anonymous-gate-actions-automation.test.ts b/packages/runtime/src/domains/anonymous-gate-actions-automation.test.ts index 9036509a9ca..f1cdf72a468 100644 --- a/packages/runtime/src/domains/anonymous-gate-actions-automation.test.ts +++ b/packages/runtime/src/domains/anonymous-gate-actions-automation.test.ts @@ -109,6 +109,9 @@ function makeDispatcher() { const registerFlow = vi.fn(); const unregisterFlow = vi.fn(); const listFlows = vi.fn(async () => ['crm_escalation_flow']); + // [#19543] The flow inventory's surviving HTTP read — `GET /_status` — now + // that the `GET /` flow list is retired. + const getFlowRuntimeStates = vi.fn(() => [{ name: 'crm_escalation_flow', enabled: true, bound: true }]); const ql: any = { executeAction, @@ -126,7 +129,7 @@ function makeDispatcher() { listObjects: vi.fn(async () => [objectDef]), getObject: vi.fn(async () => objectDef), }; - const automation: any = { execute, registerFlow, unregisterFlow, listFlows, handlerReady: true }; + const automation: any = { execute, registerFlow, unregisterFlow, listFlows, getFlowRuntimeStates, handlerReady: true }; const kernel: any = { context: { getService: (n: string) => @@ -136,7 +139,7 @@ function makeDispatcher() { : null, }, }; - return { dispatcher: new HttpDispatcher(kernel), executeAction, execute, registerFlow, unregisterFlow, listFlows }; + return { dispatcher: new HttpDispatcher(kernel), executeAction, execute, registerFlow, unregisterFlow, listFlows, getFlowRuntimeStates }; } const DENY_MESSAGE = 'Authentication is required to access this endpoint.'; @@ -295,7 +298,19 @@ describe('/automation — anonymous baseline covers the WHOLE domain (#5519)', ( expect(execute).not.toHaveBeenCalled(); }, 60_000); - it('401s an anonymous `GET /` — the flow inventory is not public', async () => { + it('401s an anonymous `GET /_status` — the flow inventory is not public', async () => { + const { dispatcher, getFlowRuntimeStates } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/_status', 'GET', undefined, anonUnresolved()); + + expectAnonymousDenial(r.response); + expect(getFlowRuntimeStates).not.toHaveBeenCalled(); + }, 60_000); + + it('[#19543] 401s an anonymous `GET /` too — the floor precedes routing, even for the retired flow list', async () => { + // Door ④ retired the `GET /` flow list; a transport that forwards every + // automation path still delivers it here, and the domain-wide floor + // answers before any route is resolved — an anonymous caller learns + // neither that the route is gone nor anything else about the domain. const { dispatcher, listFlows } = makeDispatcher(); const r: any = await dispatcher.handleAutomation('/', 'GET', undefined, anonUnresolved()); @@ -345,12 +360,21 @@ describe('/automation — anonymous baseline covers the WHOLE domain (#5519)', ( expect(execute).toHaveBeenCalled(); }, 60_000); - it('lets an authenticated caller list flows', async () => { + it('lets an authenticated caller read the flow inventory (`GET /_status`)', async () => { + const { dispatcher, getFlowRuntimeStates } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/_status', 'GET', undefined, authed()); + + expect(r.response.status).toBe(200); + expect(getFlowRuntimeStates).toHaveBeenCalled(); + }, 60_000); + + it('[#19543] an authenticated `GET /` is unhandled — the flow list is retired, and listFlows is never called', async () => { const { dispatcher, listFlows } = makeDispatcher(); const r: any = await dispatcher.handleAutomation('/', 'GET', undefined, authed()); - expect(r.response.status).toBe(200); - expect(listFlows).toHaveBeenCalled(); + expect(r.handled).toBe(false); + expect(r.response).toBeUndefined(); + expect(listFlows).not.toHaveBeenCalled(); }, 60_000); }); diff --git a/packages/runtime/src/domains/automation-run-read-permission-gate.test.ts b/packages/runtime/src/domains/automation-run-read-permission-gate.test.ts index 4cf175affd6..71a971de54e 100644 --- a/packages/runtime/src/domains/automation-run-read-permission-gate.test.ts +++ b/packages/runtime/src/domains/automation-run-read-permission-gate.test.ts @@ -327,7 +327,10 @@ describe('#7900 — /automation run-state reads require the sys_automation_run r * DECISION — changing any of these has to change this file too. */ const AUTHENTICATED_ONLY: Array<{ path: string; why: string }> = [ - { path: '', why: 'listFlows — flow names, not run state' }, + // [#19543] `''` (`listFlows — flow names, not run state`) USED to be + // this table's first row. Door ④ retired that route — flows are + // listed through `GET /meta/flow`, and the domain now declines + // `GET /` (`handled: false`) — so there is no route left to audit. { path: 'approval_flow', why: 'getFlow — a flow definition, metadata-plane data' }, { path: 'actions', why: 'getActionDescriptors — the deployment action catalog' }, { path: '_status', why: 'getFlowRuntimeStates — per-flow enabled/bound state' }, diff --git a/packages/runtime/src/domains/automation-write-capability-gate.test.ts b/packages/runtime/src/domains/automation-write-capability-gate.test.ts index 810740afe97..c19b7198bc6 100644 --- a/packages/runtime/src/domains/automation-write-capability-gate.test.ts +++ b/packages/runtime/src/domains/automation-write-capability-gate.test.ts @@ -457,12 +457,12 @@ describe('#10145 — /automation authoring writes require `manage_metadata`', () expect(h.resume).toHaveBeenCalled(); }); - it('the reads are untouched — GET / and GET /:name', async () => { + it('the reads are untouched — GET /:name', async () => { + // [#19543] `GET /` used to be the second read here; the flow list is + // retired (flows are listed through `GET /meta/flow`), so the one + // definition read this domain still serves is the single flow. const h = boot(); - const list = await h.dispatcher.handleAutomation('', 'GET', undefined, UNENTITLED(), undefined); - expect(statusOf(list.response)).toBe(200); - const detail = await h.dispatcher.handleAutomation(`/${FLOW}`, 'GET', undefined, UNENTITLED(), undefined); expect(statusOf(detail.response)).toBe(200); }); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index daa4fa8562b..9ae9eb04fa9 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -1821,7 +1821,12 @@ export async function classifyResumeResult( * path: sub-path after /automation/ * * Routes: - * GET / → listFlows + * GET / → RETIRED (#19543, door ④ — 「退役,统一走 + * /meta/flow」): no branch here, so the + * request falls through to `handled: false` + * and the transport's own unmatched answer. + * Flows are metadata (ADR-0106); list them + * with `GET /api/v1/meta/flow` * GET /actions → getActionDescriptors (ADR-0018; ?paradigm/?source/?category * single-string filters — validated, #7360) * GET /connectors → getConnectorDescriptors (ADR-0022; ?type single-string @@ -2035,25 +2040,34 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str } } - // GET / → listFlows + // GET / → RETIRED (#19543, door ④). The flow list used to be served + // here: `listFlows()` with no arguments, answered as bare names beside a + // literal `hasMore: false`, while its declared contract promised + // `status` / `type` / `limit` / `cursor` filters and `FlowSummary` rows — + // none of which any build ever honoured. Flows are metadata (ADR-0106) + // and `GET /api/v1/meta/flow` is their governed read, so the route was + // retired rather than implemented (maintainer ruling: 「退役,统一走 + // /meta/flow」). With no branch for it, `GET /` reaches the `handled: + // false` exit at the foot of this function, which `dispatch()` hands back + // as-is, so the transport gives its own unmatched answer: the dispatcher + // plugin never mounts GET here (Hono then answers 405 + `Allow: POST`, + // since createFlow keeps the path), and a catch-all adapter answers its + // enveloped 404. The anonymous floor above still runs first, as for every + // path of this domain. ⛔ Do not re-add a branch that answers this path — + // not even a 410: the retirement's contract is "no GET lives here", the + // same answer as a path where one was never registered. // - // [#7900 AUDIT — stays authenticated-only, with a reason] Together with - // `GET /:name`, `GET /actions`, `GET /connectors` and `GET /_status`, this - // serves FLOW-DEFINITION and REGISTRY data: names, definitions, the - // deployment's action/connector catalogs, per-flow enabled/bound state. None - // of it is `sys_automation_run`-class data — no run, no trigger record, no - // variable snapshot — so the grant the ruling names says nothing about it, - // and requiring it here would not be convergence but a SECOND policy - // invented for a different data class, which is precisely what the ruling - // forbids. Flow definitions are metadata and are governed on the metadata - // plane (`/meta`, ADR-0106); if their read posture should narrow, that is a - // metadata-plane decision and belongs to its own card. - if (parts.length === 0 && m === 'GET') { - if (typeof automationService.listFlows === 'function') { - const names = await automationService.listFlows(); - return { handled: true, response: deps.success({ flows: names, total: names.length, hasMore: false }) }; - } - } + // [#7900 AUDIT — the surviving definition reads stay authenticated-only, + // with a reason] `GET /:name`, `GET /actions`, `GET /connectors` and + // `GET /_status` serve FLOW-DEFINITION and REGISTRY data: definitions, the + // deployment's action/connector catalogs, per-flow enabled/bound state. + // None of it is `sys_automation_run`-class data — no run, no trigger + // record, no variable snapshot — so the grant the ruling names says + // nothing about it, and requiring it here would not be convergence but a + // SECOND policy invented for a different data class, which is precisely + // what the ruling forbids. Flow definitions are metadata and are governed + // on the metadata plane (`/meta`, ADR-0106); if their read posture should + // narrow, that is a metadata-plane decision and belongs to its own card. // POST / → createFlow if (parts.length === 0 && m === 'POST') { diff --git a/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts b/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts index 88e94ac1f7f..3a8a09f7ec1 100644 --- a/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts +++ b/packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts @@ -232,7 +232,14 @@ describe('[#13906 / 1A] the outage reaches the transport envelope as 503 SERVICE // // The 501 on the failed leg is the defect on the wire: byte-for-byte the // "never registered" answer, i.e. the ex-member was ADMITTED. - const AUTOMATION = 'GET /api/v1/automation'; + // + // [#19543] Driven through `GET /automation/_status` since door ④ retired + // the `GET /automation` flow list (the path is no longer mounted). The + // seam this pins is route-independent — the tenancy verdict and the + // anonymous floor run ahead of every automation route, and the domain's + // service probe answers the same 501 for each — so the three legs keep the + // readings in the table above. + const AUTOMATION = 'GET /api/v1/automation/_status'; const withKey = { headers: { 'x-api-key': RAW_EXMEMBER }, query: {} }; it('POSITIVE CONTROL on the wire: healthy `isolated` tenancy → the ex-member key is refused on the anonymous floor (401)', async () => { diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 6abd400a37c..79ee9a5d406 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -351,10 +351,22 @@ describe('HttpDispatcher', () => { ]); }); - it('should list flows via GET /', async () => { + // [#19543, door ④] The flow list is RETIRED — flows are listed through + // `GET /meta/flow`. The domain keeps no `GET /` branch, so it answers + // `handled: false` (the transport's own unmatched answer follows — + // pinned on a real socket in + // `dispatcher-plugin.anonymous-gate.integration.test.ts`), and the + // engine's name enumeration is never reached from HTTP even though the + // contract still declares it. + it('GET / is retired — unhandled, and listFlows is never called', async () => { const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); - expect(result.handled).toBe(true); - expect(result.response?.body?.data?.flows).toEqual(['flow_a', 'flow_b']); + expect(result.handled).toBe(false); + expect(result.response).toBeUndefined(); + expect(mockAutomationService.listFlows).not.toHaveBeenCalled(); + // …while POST at the same path (createFlow) is untouched. + const created = await dispatcher.handleAutomation('', 'POST', { name: 'flow_a' }, FLOW_AUTHOR()); + expect(created.handled).toBe(true); + expect(mockAutomationService.registerFlow).toHaveBeenCalledTimes(1); }); it('should return per-flow runtime enable/bound state via GET /_status', async () => { @@ -1324,18 +1336,21 @@ describe('HttpDispatcher', () => { }); describe('handleAutomation with async service', () => { + // [#19543] Probed through `GET /_status` — the flow-list route this + // case used to read is retired, and the subject is service + // RESOLUTION, which any served route of the domain exercises. it('should resolve automation service from Promise (async factory)', async () => { const mockAuto = { - listFlows: vi.fn().mockResolvedValue(['f1']), + getFlowRuntimeStates: vi.fn().mockReturnValue([{ name: 'f1', enabled: true, bound: true }]), }; (kernel as any).getService = vi.fn().mockImplementation((name: string) => { if (name === 'automation') return Promise.resolve(mockAuto); return null; }); - const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); + const result = await dispatcher.handleAutomation('_status', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); - expect(result.response?.body?.data?.flows).toEqual(['f1']); + expect(result.response?.body?.data?.flows).toEqual([{ name: 'f1', enabled: true, bound: true }]); }); // [#4093 follow-up] Was `handled: false` → 404; now 501 with the @@ -1345,7 +1360,7 @@ describe('HttpDispatcher', () => { (kernel as any).getService = vi.fn().mockResolvedValue(null); (kernel as any).services = new Map(); - const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); + const result = await dispatcher.handleAutomation('_status', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); expect(result.response?.body?.error?.message ?? '').toContain('service-automation'); @@ -1400,13 +1415,13 @@ describe('HttpDispatcher', () => { it('should work with synchronous getService returning service directly', async () => { const syncAuto = { - listFlows: vi.fn().mockResolvedValue(['flow_x']), + getFlowRuntimeStates: vi.fn().mockReturnValue([{ name: 'flow_x', enabled: true, bound: true }]), }; (kernel as any).getService = vi.fn().mockReturnValue(syncAuto); - const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); + const result = await dispatcher.handleAutomation('_status', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); - expect(result.response?.body?.data?.flows).toEqual(['flow_x']); + expect(result.response?.body?.data?.flows).toEqual([{ name: 'flow_x', enabled: true, bound: true }]); }); }); @@ -1453,13 +1468,13 @@ describe('HttpDispatcher', () => { it('should prefer getServiceAsync over getService for automation', async () => { const asyncAuto = { - listFlows: vi.fn().mockResolvedValue(['flow_async']), + getFlowRuntimeStates: vi.fn().mockReturnValue([{ name: 'flow_async', enabled: true, bound: true }]), }; (kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncAuto); - const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); + const result = await dispatcher.handleAutomation('_status', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); - expect(result.response?.body?.data?.flows).toEqual(['flow_async']); + expect(result.response?.body?.data?.flows).toEqual([{ name: 'flow_async', enabled: true, bound: true }]); expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('automation'); }); @@ -3339,10 +3354,14 @@ describe('HttpDispatcher', () => { // for a flow that never ran, and the domain served it as a 200 — a // caller (or an agent) read "flow executed" off nothing happening. it('/automation — a stub slot is an empty slot, and is never called', async () => { + // Held as a local so the assertion below reads a typed mock — the + // `stubbed()` spread types its members away (the TS2339 debt this + // file's siblings still carry in `test-typecheck-debt.json`). + const getFlowRuntimeStates = vi.fn().mockReturnValue([]); const stub = stubbed({ execute: vi.fn().mockResolvedValue({ success: true, output: undefined, durationMs: 0 }), trigger: vi.fn().mockResolvedValue({ success: true }), - listFlows: vi.fn().mockResolvedValue([]), + getFlowRuntimeStates, registerFlow: vi.fn(), }); serveOnly('automation', stub); @@ -3358,8 +3377,10 @@ describe('HttpDispatcher', () => { // one row the capability keeps the row measuring what it is named // after; the execution rows keep the ordinary caller, which is // exactly the scope line #10145 drew. + // [#19543] The GET row reads `/_status`: the flow list at `GET /` + // is retired, and a row naming it would pin a route nobody serves. const rows = [ - ['', 'GET', AUTHED_CALLER], + ['_status', 'GET', AUTHED_CALLER], ['', 'POST', FLOW_AUTHOR], ['trigger/x', 'POST', AUTHED_CALLER], ['x/trigger', 'POST', AUTHED_CALLER], @@ -3370,17 +3391,17 @@ describe('HttpDispatcher', () => { } expect(stub.execute).not.toHaveBeenCalled(); expect(stub.trigger).not.toHaveBeenCalled(); - expect(stub.listFlows).not.toHaveBeenCalled(); + expect(getFlowRuntimeStates).not.toHaveBeenCalled(); expect(stub.registerFlow).not.toHaveBeenCalled(); }); it('/automation — a degraded engine keeps serving', async () => { - const svc = degraded({ listFlows: vi.fn().mockResolvedValue(['flow_a']) }); + const svc = degraded({ getFlowRuntimeStates: vi.fn().mockReturnValue([{ name: 'flow_a', enabled: true, bound: true }]) }); serveOnly('automation', svc); - const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); + const result = await dispatcher.handleAutomation('_status', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); - expect(result.response?.body?.data?.flows).toEqual(['flow_a']); + expect(result.response?.body?.data?.flows).toEqual([{ name: 'flow_a', enabled: true, bound: true }]); }); // [#4087] The two `/storage` cases this block carried are gone with the diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 609cdeee096..e1674d9f6fa 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -277,7 +277,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [ /** * The ledger. * - * CENSUS (generated): this list holds 82 rows. + * CENSUS (generated): this list holds 81 rows. * * ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand. * `pnpm check:route-ledger-census` counts the rows below and fails when the two @@ -429,7 +429,8 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // ── automation ──────────────────────────────────────────────────────────── { route: 'POST /automation/trigger/:name', domain: '/automation', disposition: 'sdk', client: 'automation.trigger', note: 'legacy verb-first shape; duplicates execute() against a different URL — candidates for consolidation' }, - { route: 'GET /automation', domain: '/automation', disposition: 'sdk', client: 'automation.list' }, + // `GET /automation` (flow list, `automation.list`) — RETIRED by #19543 (door ④): + // unmounted, and the flow list is `GET /meta/flow`. No row, because nothing serves it. { route: 'POST /automation', domain: '/automation', disposition: 'sdk', client: 'automation.create', note: "authored metadata, so `manage_metadata` gates it (#10145): a flow definition lives on the metadata plane (ADR-0106), and this door now asks the capability every other door onto that plane already asks. Fail-closed by construction — an absent executionContext, an absent `systemPermissions` or an empty one all fall through to the refusal, 403 with code `PERMISSION_DENIED` (ADR-0112); only engine self-invocation (`isSystem`, never settable from the wire) bypasses. WHICH routes is one predicate, `isFlowAuthoringWrite` in `domains/automation.ts` — this row, PUT/DELETE `/:name` below, and (since the #10243 ruling) `POST /:name/toggle`, with the execution doors (trigger / execute / resume) deliberately outside it. Second layer, not the first: the #5519 anonymous floor answers an unidentified caller 401 here, not 403. Pinned in `domains/automation-write-capability-gate.test.ts`" }, { route: 'GET /automation/actions', domain: '/automation', disposition: 'sdk', client: 'automation.listActions' }, diff --git a/packages/runtime/test-typecheck-debt.json b/packages/runtime/test-typecheck-debt.json index 396db924e9e..aa50902b611 100644 --- a/packages/runtime/test-typecheck-debt.json +++ b/packages/runtime/test-typecheck-debt.json @@ -63,7 +63,6 @@ "TS2339: Property 'chat' does not exist on type '…'.": 1, "TS2339: Property 'execute' does not exist on type '…'.": 1, "TS2339: Property 'getLocales' does not exist on type '…'.": 2, - "TS2339: Property 'listFlows' does not exist on type '…'.": 1, "TS2339: Property 'listInbox' does not exist on type '…'.": 1, "TS2339: Property 'provider' does not exist on type '…'.": 1, "TS2339: Property 'registerFlow' does not exist on type '…'.": 1, diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index 70a38742d6e..f025c733844 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -280,8 +280,10 @@ node (`record_change`) or by its `type`, and arming happens at registration. Served by the runtime dispatcher's `/automation` domain when this service occupies the slot (paths shown with the `/api/v1` wire prefix): +There is no flow-list route here: flows are metadata (ADR-0106), so list them with +`GET /api/v1/meta/flow`. The former `GET /api/v1/automation` list was retired. + ``` -GET /api/v1/automation # list flows POST /api/v1/automation # create a flow GET /api/v1/automation/actions # action descriptors GET /api/v1/automation/connectors # connector descriptors diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index b06d6b7afcd..7ae6754898f 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -400,8 +400,6 @@ "FindReferencesToMetaResponse (type)", "FindReferencesToMetaResponseParsed (type)", "FindReferencesToMetaResponseSchema (const)", - "FlowSummary (type)", - "FlowSummarySchema (const)", "GeneratedApiDocumentation (type)", "GeneratedApiDocumentationParsed (type)", "GeneratedApiDocumentationSchema (const)", @@ -565,12 +563,6 @@ "ListDraftsResponse (type)", "ListDraftsResponseParsed (type)", "ListDraftsResponseSchema (const)", - "ListFlowsRequest (type)", - "ListFlowsRequestParsed (type)", - "ListFlowsRequestSchema (const)", - "ListFlowsResponse (type)", - "ListFlowsResponseParsed (type)", - "ListFlowsResponseSchema (const)", "ListImportJobsRequest (type)", "ListImportJobsRequestParsed (type)", "ListImportJobsRequestSchema (const)", diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index 1597a8d7902..db27d77140f 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -84,7 +84,6 @@ "api/ImportValidationConfig:trimWhitespace = true", "api/InitiateChunkedUploadRequest:chunkSize = 5242880", "api/InitiateChunkedUploadRequest:scope = \"user\"", - "api/ListFlowsRequest:limit = 50", "api/ListImportJobsRequest:limit = 50", "api/ListImportJobsRequest:offset = 0", "api/ListRunsRequest:limit = 20", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 017e706f763..b5bb42f1c97 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -684,14 +684,6 @@ "api/FindDataResponse:records", "api/FindDataResponse:total", "api/FindReferencesToMetaResponse:references", - "api/FlowSummary:enabled", - "api/FlowSummary:label", - "api/FlowSummary:lastRunAt", - "api/FlowSummary:name", - "api/FlowSummary:nodeCount", - "api/FlowSummary:status", - "api/FlowSummary:type", - "api/FlowSummary:version", "api/GeneratedApiDocumentation:generatedAt", "api/GeneratedApiDocumentation:html", "api/GeneratedApiDocumentation:markdown", @@ -986,20 +978,13 @@ "api/ListAiConversationsRequest:cursor", "api/ListAiConversationsRequest:limit", "api/ListAiConversationsResponse:conversations", + "api/ListAiConversationsResponse:hasMore", "api/ListAiPendingActionsRequest:conversationId", "api/ListAiPendingActionsRequest:limit", "api/ListAiPendingActionsRequest:status", "api/ListAiPendingActionsResponse:items", "api/ListAiPendingActionsResponse:total", "api/ListDraftsResponse:drafts", - "api/ListFlowsRequest:cursor", - "api/ListFlowsRequest:limit", - "api/ListFlowsRequest:status", - "api/ListFlowsRequest:type", - "api/ListFlowsResponse:data", - "api/ListFlowsResponse:error", - "api/ListFlowsResponse:meta", - "api/ListFlowsResponse:success", "api/ListImportJobsRequest:limit", "api/ListImportJobsRequest:object", "api/ListImportJobsRequest:offset", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index ad6270b0076..528d25a3240 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -287,8 +287,6 @@ "FindDataResponseSchema": "api/FindDataResponse", "FindReferencesToMetaResponse": "api/FindReferencesToMetaResponse", "FindReferencesToMetaResponseSchema": "api/FindReferencesToMetaResponse", - "FlowSummary": "api/FlowSummary", - "FlowSummarySchema": "api/FlowSummary", "GeneratedApiDocumentation": "api/GeneratedApiDocumentation", "GeneratedApiDocumentationSchema": "api/GeneratedApiDocumentation", "GeneratedEndpoint": "api/GeneratedEndpoint", @@ -419,10 +417,6 @@ "ListAiPendingActionsResponseSchema": "api/ListAiPendingActionsResponse", "ListDraftsResponse": "api/ListDraftsResponse", "ListDraftsResponseSchema": "api/ListDraftsResponse", - "ListFlowsRequest": "api/ListFlowsRequest", - "ListFlowsRequestSchema": "api/ListFlowsRequest", - "ListFlowsResponse": "api/ListFlowsResponse", - "ListFlowsResponseSchema": "api/ListFlowsResponse", "ListImportJobsRequest": "api/ListImportJobsRequest", "ListImportJobsRequestSchema": "api/ListImportJobsRequest", "ListImportJobsResponse": "api/ListImportJobsResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index d9998e71771..15400eb0286 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -379,8 +379,6 @@ "FindReferencesToMetaResponse": "src/api/protocol.zod.ts#FindReferencesToMetaResponse (type)", "FindReferencesToMetaResponseParsed": "src/api/protocol.zod.ts#FindReferencesToMetaResponseParsed (type)", "FindReferencesToMetaResponseSchema": "src/api/protocol.zod.ts#FindReferencesToMetaResponseSchema (const)", - "FlowSummary": "src/api/automation-api.zod.ts#FlowSummary (type)", - "FlowSummarySchema": "src/api/automation-api.zod.ts#FlowSummarySchema (const)", "GeneratedApiDocumentation": "src/api/documentation.zod.ts#GeneratedApiDocumentation (type)", "GeneratedApiDocumentationParsed": "src/api/documentation.zod.ts#GeneratedApiDocumentationParsed (type)", "GeneratedApiDocumentationSchema": "src/api/documentation.zod.ts#GeneratedApiDocumentationSchema (const)", @@ -540,12 +538,6 @@ "ListDraftsResponse": "src/api/protocol.zod.ts#ListDraftsResponse (type)", "ListDraftsResponseParsed": "src/api/protocol.zod.ts#ListDraftsResponseParsed (type)", "ListDraftsResponseSchema": "src/api/protocol.zod.ts#ListDraftsResponseSchema (const)", - "ListFlowsRequest": "src/api/automation-api.zod.ts#ListFlowsRequest (type)", - "ListFlowsRequestParsed": "src/api/automation-api.zod.ts#ListFlowsRequestParsed (type)", - "ListFlowsRequestSchema": "src/api/automation-api.zod.ts#ListFlowsRequestSchema (const)", - "ListFlowsResponse": "src/api/automation-api.zod.ts#ListFlowsResponse (type)", - "ListFlowsResponseParsed": "src/api/automation-api.zod.ts#ListFlowsResponseParsed (type)", - "ListFlowsResponseSchema": "src/api/automation-api.zod.ts#ListFlowsResponseSchema (const)", "ListImportJobsRequest": "src/api/export.zod.ts#ListImportJobsRequest (type)", "ListImportJobsRequestParsed": "src/api/export.zod.ts#ListImportJobsRequestParsed (type)", "ListImportJobsRequestSchema": "src/api/export.zod.ts#ListImportJobsRequestSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 8c11f3d0c22..2d23fc6dce5 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -158,7 +158,6 @@ "api/FindDataRequest", "api/FindDataResponse", "api/FindReferencesToMetaResponse", - "api/FlowSummary", "api/GeneratedApiDocumentation", "api/GeneratedEndpoint", "api/GetAnalyticsMetaRequest", @@ -231,8 +230,6 @@ "api/ListAiPendingActionsRequest", "api/ListAiPendingActionsResponse", "api/ListDraftsResponse", - "api/ListFlowsRequest", - "api/ListFlowsResponse", "api/ListImportJobsRequest", "api/ListImportJobsResponse", "api/ListInstalledPackagesRequest", diff --git a/packages/spec/src/api/automation-api.zod.test.ts b/packages/spec/src/api/automation-api.zod.test.ts index 06ffab6aada..4dd147a752c 100644 --- a/packages/spec/src/api/automation-api.zod.test.ts +++ b/packages/spec/src/api/automation-api.zod.test.ts @@ -2,9 +2,6 @@ import { describe, it, expect } from 'vitest'; import { AutomationFlowPathParamsSchema, AutomationRunPathParamsSchema, - ListFlowsRequestSchema, - FlowSummarySchema, - ListFlowsResponseSchema, GetFlowRequestSchema, GetFlowResponseSchema, CreateFlowRequestSchema, @@ -25,6 +22,7 @@ import { AutomationApiContracts, ResumeFailureDetailsSchema, } from './automation-api.zod'; +import * as AutomationApiModule from './automation-api.zod'; import type { TriggerFlowResponse, ResumeFailureDetails } from './automation-api.zod'; import { ExecutionStatus } from '../automation/execution.zod'; import type { AutomationResult } from '../contracts/automation-service'; @@ -88,82 +86,25 @@ describe('AutomationRunPathParamsSchema', () => { }); // ========================================== -// List Flows +// List Flows — RETIRED (#19543, door ④) // ========================================== -describe('ListFlowsRequestSchema', () => { - it('should accept minimal request with defaults', () => { - const result = ListFlowsRequestSchema.parse({}); - expect(result.limit).toBe(50); - expect(result.status).toBeUndefined(); - expect(result.type).toBeUndefined(); - }); - - it('should accept full request', () => { - const result = ListFlowsRequestSchema.parse({ - status: 'active', - type: 'schedule', - limit: 10, - cursor: 'abc123', - }); - expect(result.status).toBe('active'); - expect(result.type).toBe('schedule'); - expect(result.limit).toBe(10); - }); - - it('should reject invalid status', () => { - expect(() => ListFlowsRequestSchema.parse({ status: 'running' })).toThrow(); - }); -}); - -describe('FlowSummarySchema', () => { - it('should accept a valid flow summary', () => { - const result = FlowSummarySchema.parse({ - name: 'approval_flow', - label: 'Approval Flow', - type: 'autolaunched', - status: 'active', - version: 1, - enabled: true, - }); - expect(result.name).toBe('approval_flow'); - expect(result.enabled).toBe(true); - }); - - it('should accept summary with optional fields', () => { - const result = FlowSummarySchema.parse({ - name: 'daily_sync', - label: 'Daily Sync', - type: 'schedule', - status: 'active', - version: 3, - enabled: true, - nodeCount: 12, - lastRunAt: '2026-02-01T10:00:00Z', - }); - expect(result.nodeCount).toBe(12); - expect(result.lastRunAt).toBe('2026-02-01T10:00:00Z'); - }); -}); - -describe('ListFlowsResponseSchema', () => { - it('should accept a valid response', () => { - const result = ListFlowsResponseSchema.parse({ - success: true, - data: { - flows: [{ - name: 'test_flow', - label: 'Test', - type: 'api', - status: 'draft', - version: 1, - enabled: false, - }], - hasMore: false, - }, - }); - expect(result.data.flows).toHaveLength(1); - expect(result.data.hasMore).toBe(false); +describe('the GET /api/v1/automation flow-list door is retired (#19543)', () => { + // Flows are metadata (ADR-0106); the list is `GET /api/v1/meta/flow`. The + // three schemas left with the route and are registered as whole-def removals + // (`RETIRED_DEFS_BY_MAJOR[18]`), so an export that came back would be a + // declaration nothing serves — exactly what the retirement removed. + it.each(['ListFlowsRequestSchema', 'ListFlowsResponseSchema', 'FlowSummarySchema'])( + '%s is no longer exported', + (name) => { + expect(Object.keys(AutomationApiModule)).not.toContain(name); + }, + ); + + it('the module still exports its surviving request schemas (the absence above is not a dead import)', () => { + expect(Object.keys(AutomationApiModule)).toEqual( + expect.arrayContaining(['GetFlowRequestSchema', 'CreateFlowRequestSchema', 'ListRunsRequestSchema']), + ); }); }); @@ -783,12 +724,22 @@ describe('AutomationApiErrorCode', () => { // ========================================== describe('AutomationApiContracts', () => { - it('should define all 9 contract endpoints', () => { - expect(Object.keys(AutomationApiContracts)).toHaveLength(9); + it('should define all 8 contract endpoints', () => { + // 9 -> 8: `listFlows` (`GET /api/v1/automation`) retired with its route + // (#19543) — flows are listed through `GET /api/v1/meta/flow`. + expect(Object.keys(AutomationApiContracts)).toHaveLength(8); + }); + + it('declares no flow-list entry and no GET at the bare /api/v1/automation path (#19543)', () => { + expect(Object.keys(AutomationApiContracts)).not.toContain('listFlows'); + const routes = Object.values(AutomationApiContracts).map((c) => `${c.method} ${c.path}`); + expect(routes).not.toContain('GET /api/v1/automation'); + // …while the create door at the same path survives, so the absence is the + // one verb and not the whole path. + expect(routes).toContain('POST /api/v1/automation'); }); it('should define correct HTTP methods', () => { - expect(AutomationApiContracts.listFlows.method).toBe('GET'); expect(AutomationApiContracts.getFlow.method).toBe('GET'); expect(AutomationApiContracts.createFlow.method).toBe('POST'); expect(AutomationApiContracts.updateFlow.method).toBe('PUT'); @@ -800,7 +751,6 @@ describe('AutomationApiContracts', () => { }); it('should define correct paths', () => { - expect(AutomationApiContracts.listFlows.path).toBe('/api/v1/automation'); expect(AutomationApiContracts.getFlow.path).toBe('/api/v1/automation/:name'); expect(AutomationApiContracts.createFlow.path).toBe('/api/v1/automation'); expect(AutomationApiContracts.updateFlow.path).toBe('/api/v1/automation/:name'); diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index 829a72b2950..c4d86b26972 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -19,9 +19,14 @@ import { ExecutionLogSchema, ExecutionStatus, FlowRunSummarySchema } from '../au * (`automation-api-contract-mounts.test.ts`) holds every `path` in * {@link AutomationApiContracts} to that mount table. * + * The flow LIST is not on this door. Flows are metadata (ADR-0106), and the + * governed read of them is `GET /api/v1/meta/flow` (`client.meta.getItems`); + * the former `GET /api/v1/automation` list route, its request/response schemas + * and `client.automation.list` are retired (ADR-0087 semantic entry + * `automation-flow-list-route-retired`). + * * @example Endpoints * ``` - * GET /api/v1/automation — List flows * GET /api/v1/automation/:name — Get flow * POST /api/v1/automation — Create flow * PUT /api/v1/automation/:name — Update flow @@ -56,57 +61,20 @@ export const AutomationRunPathParamsSchema = lazySchema(() => AutomationFlowPath export type AutomationRunPathParams = z.input; // ========================================== -// 2. List Flows (GET /api/v1/automation) +// 2. List Flows — RETIRED (#19543, door ④) // ========================================== - -/** - * Query parameters for listing automation flows. - * - * @example GET /api/v1/automation?status=active&limit=20 - */ -export const ListFlowsRequestSchema = lazySchema(() => z.object({ - status: z.enum(['draft', 'active', 'obsolete', 'invalid']).optional() - .describe('Filter by flow status'), - type: z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api']).optional() - .describe('Filter by flow type'), - limit: z.number().int().min(1).max(100).default(50) - .describe('Maximum number of flows to return'), - cursor: z.string().optional() - .describe('Cursor for pagination'), -})); -export type ListFlowsRequest = z.input; -/** Post-parse shape of {@link ListFlowsRequest} — defaults applied, transforms run (ADR-0122). */ -export type ListFlowsRequestParsed = z.infer; - -/** - * Summary information for a flow in list results. - */ -export const FlowSummarySchema = lazySchema(() => z.object({ - name: z.string().describe('Flow machine name'), - label: z.string().describe('Flow display label'), - type: z.string().describe('Flow type'), - status: z.string().describe('Flow deployment status'), - version: z.number().int().describe('Flow version number'), - enabled: z.boolean().describe('Whether the flow is enabled for execution'), - nodeCount: z.number().int().optional().describe('Number of nodes in the flow'), - lastRunAt: z.string().datetime().optional().describe('Last execution timestamp'), -})); -export type FlowSummary = z.input; - -/** - * Response for the list flows endpoint. - */ -export const ListFlowsResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - flows: z.array(FlowSummarySchema).describe('Flow summaries'), - total: z.number().int().optional().describe('Total matching flows'), - nextCursor: z.string().optional().describe('Cursor for the next page'), - hasMore: z.boolean().describe('Whether more flows are available'), - }), -})); -export type ListFlowsResponse = z.input; -/** Post-parse shape of {@link ListFlowsResponse} — defaults applied, transforms run (ADR-0122). */ -export type ListFlowsResponseParsed = z.infer; +// +// `ListFlowsRequestSchema`, `ListFlowsResponseSchema` and `FlowSummarySchema` +// were removed with the `GET /api/v1/automation` list route (maintainer +// ruling: 「退役,统一走 /meta/flow」). The route read none of its declared +// request (`status` / `type` / `limit` / `cursor`) and answered bare flow +// names with a literal `hasMore: false` where the response declared +// `FlowSummary[]` and a `nextCursor`, and it had zero callers in this +// repository, objectui and cloud. Flows are metadata (ADR-0106): the list is +// `GET /api/v1/meta/flow`. Registered as whole-def removals in +// `RETIRED_DEFS_BY_MAJOR[18]` and as the D3 semantic entry +// `automation-flow-list-route-retired`. The section number stays vacant so the +// sections below keep the numbers other files cite. // ========================================== // 3. Get Flow (GET /api/v1/automation/:name) @@ -659,12 +627,9 @@ export type AutomationApiErrorCode = z.input; * Used for generating SDKs, documentation, and route registration. */ export const AutomationApiContracts = { - listFlows: { - method: 'GET' as const, - path: '/api/v1/automation', - input: ListFlowsRequestSchema, - output: ListFlowsResponseSchema, - }, + // No `listFlows` entry: the list route is retired (#19543) — flows are read + // through `GET /api/v1/meta/flow`. `POST /api/v1/automation` (createFlow) + // below is unaffected and stays at the same path. getFlow: { method: 'GET' as const, path: '/api/v1/automation/:name', diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 179b7c2e649..10a68d2fc96 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -49,6 +49,7 @@ import { AiCompleteRequestSchema, AiModelsResponseSchema, CreateAiConversationRequestSchema, + ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, UpdateAiConversationRequestSchema, // i18n @@ -384,6 +385,7 @@ describe('ObjectStack Protocol', () => { id: 'conv_1', messages: [{ role: 'user', content: 'hi' }], createdAt: '2026-07-27T10:00:00Z', updatedAt: '2026-07-27T10:00:00Z', }], + hasMore: false, }).success).toBe(true); expect(UpdateAiConversationRequestSchema.safeParse({ title: 'Renamed' }).success).toBe(true); expect( @@ -2720,3 +2722,46 @@ describe('GetPublishedMetaItemResponseSchema stays opaque by ruling (#12038 1C)' expect(GetPublishedMetaItemResponseSchema.safeParse(null).success).toBe(true); }); }); + +describe('ListAiConversationsResponseSchema declares the next-page signal (#19543, door ③)', () => { + // Ruled on #19543: the list is newest first and pages by keyset, `cursor` + // being the id of the last conversation the caller holds. The response used + // to be `{ conversations }` alone, so a caller asking for `limit` rows could + // not tell a full last page from a truncated one. The server half is + // objectstack-ai/cloud#2426. + const conv = (id: string) => ({ + id, messages: [], createdAt: '2026-09-27T10:00:00Z', updatedAt: '2026-09-27T10:00:00Z', + }); + + it('accepts a page carrying `hasMore`, both values, and keeps it through parse', () => { + const more = ListAiConversationsResponseSchema.parse({ conversations: [conv('c2'), conv('c1')], hasMore: true }); + expect(more.hasMore).toBe(true); + expect(more.conversations.map((c) => c.id)).toEqual(['c2', 'c1']); + expect(ListAiConversationsResponseSchema.parse({ conversations: [], hasMore: false }).hasMore).toBe(false); + }); + + it('REFUSES a page without `hasMore` — the flag is required, never an absent-means-unknown optional', () => { + const r = ListAiConversationsResponseSchema.safeParse({ conversations: [conv('c1')] }); + expect(r.success).toBe(false); + expect(r.error!.issues).toHaveLength(1); + expect(r.error!.issues[0]!.code).toBe('invalid_type'); + expect(r.error!.issues[0]!.path).toEqual(['hasMore']); + expect(r.error!.issues[0]!.message).toBe('Invalid input: expected boolean, received undefined'); + }); + + it('refuses a non-boolean `hasMore` — a stringly "false" is not a page signal', () => { + const r = ListAiConversationsResponseSchema.safeParse({ conversations: [], hasMore: 'false' }); + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.code).toBe('invalid_type'); + expect(r.error!.issues[0]!.path).toEqual(['hasMore']); + }); + + it('declares no `nextCursor` — the next cursor is the last conversation\'s id, already on the page', () => { + expect(Object.keys((ListAiConversationsResponseSchema as any).shape)).toEqual(['conversations', 'hasMore']); + }); + + it('the request keeps its three keys — `cursor` is described, not reshaped', () => { + expect(Object.keys((ListAiConversationsRequestSchema as any).shape)).toEqual(['agentId', 'limit', 'cursor']); + expect(ListAiConversationsRequestSchema.parse({ limit: 20, cursor: 'c1' })).toEqual({ limit: 20, cursor: 'c1' }); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index e0e85901e27..1795c74e649 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2944,15 +2944,44 @@ export const CreateAiConversationRequestSchema = lazySchema(() => z.object({ metadata: z.record(z.string(), z.unknown()).optional().describe('Conversation metadata'), })); -/** `GET /api/v1/ai/conversations` query — scoped to the authenticated user. */ +/** + * `GET /api/v1/ai/conversations` query — scoped to the authenticated user. + * + * [#19543, door ③] The list is NEWEST FIRST (maintainer ruling on that card: + * 「Ruled: the list is newest first.」), and it pages by keyset: `cursor` is the + * `id` of the last conversation the caller already holds — no opaque token is + * minted, so a caller that has a page has its next cursor. The server half + * (descending order, the flipped keyset, `hasMore` and the unknown-cursor + * refusal) is objectstack-ai/cloud#2426; the ruling lets this declaration land + * first. + */ export const ListAiConversationsRequestSchema = lazySchema(() => z.object({ agentId: z.string().optional().describe('Filter by agent'), limit: z.number().int().positive().optional().describe('Maximum conversations to return'), - cursor: z.string().optional().describe('Pagination cursor'), + cursor: z.string().optional().describe( + 'The `id` of the last conversation on the previous page. The next page starts with the ' + + 'conversation created immediately before it, continuing newest first. Omit it to read ' + + 'the first page. An id that names no conversation of the caller is refused rather than ' + + 'read as the start of the list.', + ), })); export const ListAiConversationsResponseSchema = lazySchema(() => z.object({ - conversations: z.array(AiConversationSchema).describe('Matching conversations'), + conversations: z.array(AiConversationSchema).describe( + 'The caller\'s conversations, newest first — ordered by creation time, then `id`, both descending', + ), + // [#19543, door ③] REQUIRED, not optional: an optional flag lets a server + // that never computes it stay spec-valid forever, and a caller cannot tell + // "no further page" from "this server does not say". The ruling makes the + // server compute it (objectstack-ai/cloud#2426, over-read by one row). + // `nextCursor` is deliberately NOT declared: the ruling defines it as the + // id of the last conversation on the page, which every caller already + // holds in `conversations`, so a second field would only be a second place + // for the same value to disagree. + hasMore: z.boolean().describe( + 'Whether at least one more conversation follows this page. When `true`, send the `id` of ' + + 'the last conversation in `conversations` as `cursor` to read the next page.', + ), })); /** diff --git a/packages/spec/src/migrations/entries/retired-defs/18.api__FlowSummary.ts b/packages/spec/src/migrations/entries/retired-defs/18.api__FlowSummary.ts new file mode 100644 index 00000000000..6b0b131559e --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.api__FlowSummary.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #19543 (door ④) — `api/FlowSummary` left with its only reader, +// `api/ListFlowsResponse` (above). No producer ever built one: the retired +// list route answered bare names, so the summary's `label` / `type` / +// `status` / `version` / `enabled` / `nodeCount` / `lastRunAt` were a shape +// with no emitter, and an exported schema with no consumer reads as a +// capability (#3950, the `ui/ThemeMode` rule). Measured before removal: zero +// readers in objectstack, objectui (pinned sha and main) or cloud. A flow's +// runtime enablement is served by `GET /api/v1/automation/_status`; its +// definition by `GET /api/v1/meta/flow`. +export const entry = 'api/FlowSummary'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsRequest.ts b/packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsRequest.ts new file mode 100644 index 00000000000..a0e7f07c95a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsRequest.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #19543 (door ④) — `api/ListFlowsRequest`, the query of the retired +// `GET /api/v1/automation` flow list (maintainer ruling on #19543: +// 「退役,统一走 /meta/flow」). It declared `status` / `type` / `limit` +// (default 50) / `cursor`, and the route read none of them: it called +// `listFlows()` with no arguments. Retired whole with the route and its +// `AutomationApiContracts.listFlows` entry; flows are metadata (ADR-0106) and +// the list is `GET /api/v1/meta/flow`. Zero readers measured before removal in +// objectstack, objectui (pinned sha and main) and cloud. No carrier key and no +// authored document, so no tombstone and no D2 conversion — this table plus +// the D3 semantic entry `automation-flow-list-route-retired` ARE the +// declaration — the whole-def route-3 shape, as the precedent entry +// `package-rollback-response-retired` (and its `api/PackageRollbackResponse` +// row) recorded it. +export const entry = 'api/ListFlowsRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsResponse.ts b/packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsResponse.ts new file mode 100644 index 00000000000..726308d42ed --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsResponse.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #19543 (door ④) — `api/ListFlowsResponse`, the answer of the retired +// `GET /api/v1/automation` flow list. It declared `FlowSummary[]`, `total`, +// `nextCursor` and `hasMore`, while the route answered bare flow NAMES with a +// literal `hasMore: false` and never a `nextCursor` — a declaration no build +// ever served. Retired whole with the route; the list is `GET /api/v1/meta/flow`. +// See `18.api__ListFlowsRequest.ts` and the D3 semantic entry +// `automation-flow-list-route-retired` for the record. +export const entry = 'api/ListFlowsResponse'; diff --git a/packages/spec/src/migrations/entries/semantic/18.automation-flow-list-route-retired.ts b/packages/spec/src/migrations/entries/semantic/18.automation-flow-list-route-retired.ts new file mode 100644 index 00000000000..b63a91ab13a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.automation-flow-list-route-retired.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'automation-flow-list-route-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'GET /api/v1/automation — the flow-list route of the automation door, together with ' + + 'its request and response schemas ListFlowsRequestSchema and ListFlowsResponseSchema ' + + '(and their ListFlowsRequest, ListFlowsRequestParsed, ListFlowsResponse and ' + + 'ListFlowsResponseParsed types), FlowSummarySchema and its FlowSummary type, the ' + + 'listFlows entry of AutomationApiContracts, and the automation.list method of ' + + '@objectstack/client. Every other automation route is unchanged, including ' + + 'POST /api/v1/automation (create a flow) at the same path', + replacement: + 'GET /api/v1/meta/flow — flows are metadata (ADR-0106), and this is the governed read of ' + + 'them; from the SDK it is `client.meta.getItems` with the type `flow`. It answers the ' + + 'full flow definitions rather than bare names, so a caller that only needs the names ' + + 'maps each item to its `name`. The runtime enablement and trigger binding of every flow ' + + '— the one piece of engine state a definition does not carry — is ' + + '`GET /api/v1/automation/_status` (`client.automation.getRuntimeStatus`), which is ' + + 'unchanged', + reason: + 'Maintainer ruling on #19543 (door ④, verbatim 「退役,统一走 /meta/flow」, recorded in ' + + 'that card\'s re-derivation comment of 2026-09-25), under ADR-0049 enforce-or-remove. The ' + + 'route\'s contract described a capability nobody built: ListFlowsRequestSchema declared ' + + '`status`, `type`, `limit` (default 50) and `cursor`, and the handler read none of them — ' + + 'it asked the automation service for its flow names with no arguments at all. ' + + 'ListFlowsResponseSchema declared a page of FlowSummary rows with `total`, `nextCursor` ' + + 'and `hasMore`, and the handler answered a bare array of names beside a literal ' + + '`hasMore: false`. So a caller filtering by status received every flow, a caller paging ' + + 'with a cursor re-read the only page forever, and a caller reading FlowSummary fields read ' + + 'undefined — each with a 200 and no error. ' + + 'Measured before removal, on the main branch of this repository and cloud and on objectui at ' + + 'both its pinned commit and main: zero callers of the route or of the SDK method outside ' + + 'their own tests, while both real flow lists in the product — the Console flow-runs page ' + + 'and the Setup packaged-automation page — already read GET /api/v1/meta/flow. ' + + 'Implementing the declared contract instead would have built a second, weaker metadata list ' + + 'beside the governed one; retiring it leaves one read. ' + + 'There is no alias and no transition window: GET simply stops being mounted there. There is ' + + 'no D2 conversion and no tombstone, because the shape is HTTP-only — nobody authors a ' + + 'ListFlowsRequest and nothing persists one — so the three schemas are whole-def removals in ' + + 'RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106, ' + + '#19543.', + acceptanceCriteria: + 'On the composition `objectstack serve` builds, GET is no longer mounted at ' + + '/api/v1/automation (nor at its environment-scoped twin), so the host gives its standard ' + + 'unmatched answer with no residual refusal text of its own. Because POST still lives at ' + + 'that path, on the Hono host that answer is 405 METHOD_NOT_ALLOWED with an Allow header ' + + 'naming POST — the same answer any path where only another verb is registered gets, for ' + + 'anonymous and signed-in callers alike. A transport that forwards every automation path ' + + 'to the dispatcher is told the domain does not handle it and answers its own not-found ' + + '404 (the @objectstack/hono catch-all does), and there the domain\'s anonymous floor still ' + + 'answers an unidentified caller 401 first, as it does for every automation path. The ' + + 'automation ' + + 'service\'s flow-name enumeration is never called by any HTTP request. The route-ledger row ' + + 'for the route is gone, AutomationApiContracts has eight entries and none of them is a GET ' + + 'at the bare path, and a TypeScript import of any of the removed schemas or types is a ' + + 'compile error (TS2305). @objectstack/client no longer declares automation.list, so a call ' + + 'to it is a compile error rather than a request to a path that no longer answers. ' + + 'POST /api/v1/automation still creates a flow, and every other automation route — the ' + + 'single-flow reads and writes, trigger, toggle, clone, runs, resume, cancel, ' + + 'restore-suspension, screen, _status and the actions and connectors catalogs — answers ' + + 'exactly as before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9a266436f8a..48ee4f5c311 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5983,6 +5983,69 @@ const step18: MigrationStep = { '(invitation, admin create-user / import, SCIM, or an operator-registered identity provider) ' + 'and that anonymous sign-up now answers 403 SELF_REGISTRATION_CLOSED.', }, + { + id: 'automation-flow-list-route-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'GET /api/v1/automation — the flow-list route of the automation door, together with ' + + 'its request and response schemas ListFlowsRequestSchema and ListFlowsResponseSchema ' + + '(and their ListFlowsRequest, ListFlowsRequestParsed, ListFlowsResponse and ' + + 'ListFlowsResponseParsed types), FlowSummarySchema and its FlowSummary type, the ' + + 'listFlows entry of AutomationApiContracts, and the automation.list method of ' + + '@objectstack/client. Every other automation route is unchanged, including ' + + 'POST /api/v1/automation (create a flow) at the same path', + replacement: + 'GET /api/v1/meta/flow — flows are metadata (ADR-0106), and this is the governed read of ' + + 'them; from the SDK it is `client.meta.getItems` with the type `flow`. It answers the ' + + 'full flow definitions rather than bare names, so a caller that only needs the names ' + + 'maps each item to its `name`. The runtime enablement and trigger binding of every flow ' + + '— the one piece of engine state a definition does not carry — is ' + + '`GET /api/v1/automation/_status` (`client.automation.getRuntimeStatus`), which is ' + + 'unchanged', + reason: + 'Maintainer ruling on #19543 (door ④, verbatim 「退役,统一走 /meta/flow」, recorded in ' + + 'that card\'s re-derivation comment of 2026-09-25), under ADR-0049 enforce-or-remove. The ' + + 'route\'s contract described a capability nobody built: ListFlowsRequestSchema declared ' + + '`status`, `type`, `limit` (default 50) and `cursor`, and the handler read none of them — ' + + 'it asked the automation service for its flow names with no arguments at all. ' + + 'ListFlowsResponseSchema declared a page of FlowSummary rows with `total`, `nextCursor` ' + + 'and `hasMore`, and the handler answered a bare array of names beside a literal ' + + '`hasMore: false`. So a caller filtering by status received every flow, a caller paging ' + + 'with a cursor re-read the only page forever, and a caller reading FlowSummary fields read ' + + 'undefined — each with a 200 and no error. ' + + 'Measured before removal, on the main branch of this repository and cloud and on objectui at ' + + 'both its pinned commit and main: zero callers of the route or of the SDK method outside ' + + 'their own tests, while both real flow lists in the product — the Console flow-runs page ' + + 'and the Setup packaged-automation page — already read GET /api/v1/meta/flow. ' + + 'Implementing the declared contract instead would have built a second, weaker metadata list ' + + 'beside the governed one; retiring it leaves one read. ' + + 'There is no alias and no transition window: GET simply stops being mounted there. There is ' + + 'no D2 conversion and no tombstone, because the shape is HTTP-only — nobody authors a ' + + 'ListFlowsRequest and nothing persists one — so the three schemas are whole-def removals in ' + + 'RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106, ' + + '#19543.', + acceptanceCriteria: + 'On the composition `objectstack serve` builds, GET is no longer mounted at ' + + '/api/v1/automation (nor at its environment-scoped twin), so the host gives its standard ' + + 'unmatched answer with no residual refusal text of its own. Because POST still lives at ' + + 'that path, on the Hono host that answer is 405 METHOD_NOT_ALLOWED with an Allow header ' + + 'naming POST — the same answer any path where only another verb is registered gets, for ' + + 'anonymous and signed-in callers alike. A transport that forwards every automation path ' + + 'to the dispatcher is told the domain does not handle it and answers its own not-found ' + + '404 (the @objectstack/hono catch-all does), and there the domain\'s anonymous floor still ' + + 'answers an unidentified caller 401 first, as it does for every automation path. The ' + + 'automation ' + + 'service\'s flow-name enumeration is never called by any HTTP request. The route-ledger row ' + + 'for the route is gone, AutomationApiContracts has eight entries and none of them is a GET ' + + 'at the bare path, and a TypeScript import of any of the removed schemas or types is a ' + + 'compile error (TS2305). @objectstack/client no longer declares automation.list, so a call ' + + 'to it is a compile error rather than a request to a path that no longer answers. ' + + 'POST /api/v1/automation still creates a flow, and every other automation route — the ' + + 'single-flow reads and writes, trigger, toggle, clone, runs, resume, cancel, ' + + 'restore-suspension, screen, _status and the actions and connectors catalogs — answers ' + + 'exactly as before.', + }, { id: 'automation-runs-cursor-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a @@ -18559,6 +18622,16 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // conversion — this table plus the D3 semantic entry // `export-job-family-retired` are the declaration. 'api/ExportJobSummary', + // #19543 (door ④) — `api/FlowSummary` left with its only reader, + // `api/ListFlowsResponse` (above). No producer ever built one: the retired + // list route answered bare names, so the summary's `label` / `type` / + // `status` / `version` / `enabled` / `nodeCount` / `lastRunAt` were a shape + // with no emitter, and an exported schema with no consumer reads as a + // capability (#3950, the `ui/ThemeMode` rule). Measured before removal: zero + // readers in objectstack, objectui (pinned sha and main) or cloud. A flow's + // runtime enablement is served by `GET /api/v1/automation/_status`; its + // definition by `GET /api/v1/meta/flow`. + 'api/FlowSummary', // #17158 — `api/GetExportJobDownloadRequest`, retired whole with the export-job API family // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui // retired its side first in objectui#10247). It declared @@ -18610,6 +18683,28 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // conversion — this table plus the D3 semantic entry // `export-job-family-retired` are the declaration. 'api/ListExportJobsResponse', + // #19543 (door ④) — `api/ListFlowsRequest`, the query of the retired + // `GET /api/v1/automation` flow list (maintainer ruling on #19543: + // 「退役,统一走 /meta/flow」). It declared `status` / `type` / `limit` + // (default 50) / `cursor`, and the route read none of them: it called + // `listFlows()` with no arguments. Retired whole with the route and its + // `AutomationApiContracts.listFlows` entry; flows are metadata (ADR-0106) and + // the list is `GET /api/v1/meta/flow`. Zero readers measured before removal in + // objectstack, objectui (pinned sha and main) and cloud. No carrier key and no + // authored document, so no tombstone and no D2 conversion — this table plus + // the D3 semantic entry `automation-flow-list-route-retired` ARE the + // declaration — the whole-def route-3 shape, as the precedent entry + // `package-rollback-response-retired` (and its `api/PackageRollbackResponse` + // row) recorded it. + 'api/ListFlowsRequest', + // #19543 (door ④) — `api/ListFlowsResponse`, the answer of the retired + // `GET /api/v1/automation` flow list. It declared `FlowSummary[]`, `total`, + // `nextCursor` and `hasMore`, while the route answered bare flow NAMES with a + // literal `hasMore: false` and never a `nextCursor` — a declaration no build + // ever served. Retired whole with the route; the list is `GET /api/v1/meta/flow`. + // See `18.api__ListFlowsRequest.ts` and the D3 semantic entry + // `automation-flow-list-route-retired` for the record. + 'api/ListFlowsResponse', // #13135 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on // #12057: retirement adopted, re-scope rejected; re-charter #13135 executes // the widened surface). Part of the whole-module removal of diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 1a368cab282..f51e1475899 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 787 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 786 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -428,7 +428,6 @@ export type Iso_api_automationApi__AutomationApiErrorCode = Assert, z.infer< typeof M14.AutomationFlowPathParamsSchema > >>; export type Iso_api_automationApi__AutomationRunPathParamsSchema = Assert, z.infer< typeof M14.AutomationRunPathParamsSchema > >>; export type Iso_api_automationApi__DeleteFlowRequestSchema = Assert, z.infer< typeof M14.DeleteFlowRequestSchema > >>; -export type Iso_api_automationApi__FlowSummarySchema = Assert, z.infer< typeof M14.FlowSummarySchema > >>; export type Iso_api_automationApi__GetFlowRequestSchema = Assert, z.infer< typeof M14.GetFlowRequestSchema > >>; export type Iso_api_automationApi__GetRunRequestSchema = Assert, z.infer< typeof M14.GetRunRequestSchema > >>; export type Iso_api_automationApi__ToggleFlowRequestSchema = Assert, z.infer< typeof M14.ToggleFlowRequestSchema > >>; @@ -1659,7 +1658,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 787 isomorphic pins', () => { + it('still declares all 786 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2292,7 +2291,21 @@ describe('ADR-0122 type-alias convention', () => { // never on this list, so they leave with nothing to unpin; `ScheduleState` // (retired in the same change) carried `ScheduleStateParsed` likewise. The M22 // slot stays occupied by the module's surviving import-job pins. -3 removed. - expect(pins).toHaveLength(787); + // + // 787 -> 786 is #19543's retirement of the `GET /api/v1/automation` flow + // list (door ④, maintainer ruling 「退役,统一走 /meta/flow」): + // `FlowSummarySchema` left with its only reader, `ListFlowsResponseSchema` + // (whole-def removal, `RETIRED_DEFS_BY_MAJOR[18]` `api/FlowSummary`), so + // Iso_api_automationApi__FlowSummarySchema leaves with the schema. The two + // list schemas were never on this list — each was declared with an + // `XParsed` alias (`ListFlowsRequestParsed` / `ListFlowsResponseParsed`), + // and those leave with them. The M14 slot stays occupied by the module's + // surviving pins. + // The two retirements were authored in parallel off the same 790 and + // touch disjoint pins (M22's three, M14's one); #17158 landed first, so + // this entry's arrow starts from its 787. The count below was re-derived + // from the merged file, not added up. -1 removed. + expect(pins).toHaveLength(786); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either