From 477a8f50c801d8f38c16e32b74b7f37f4bf3dad1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 02:36:17 +0000 Subject: [PATCH 01/11] feat(spec)!: retire the GET /api/v1/automation flow-list contract; ListAiConversationsResponse gains hasMore Door 4: ListFlowsRequestSchema, ListFlowsResponseSchema, FlowSummarySchema and the AutomationApiContracts listFlows entry leave with the route; the list is GET /api/v1/meta/flow. Registered as three RETIRED_DEFS_BY_MAJOR[18] rows and the D3 semantic entry automation-flow-list-route-retired. Door 3 (spec half): ListAiConversationsResponseSchema gains a required hasMore, cursor is described as the id of the last conversation held, and the list is declared newest first. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .../spec/src/api/automation-api.zod.test.ts | 112 +++++------------- packages/spec/src/api/automation-api.zod.ts | 79 ++++-------- packages/spec/src/api/protocol.test.ts | 45 +++++++ packages/spec/src/api/protocol.zod.ts | 35 +++++- .../retired-defs/18.api__FlowSummary.ts | 12 ++ .../retired-defs/18.api__ListFlowsRequest.ts | 14 +++ .../retired-defs/18.api__ListFlowsResponse.ts | 10 ++ .../18.automation-flow-list-route-retired.ts | 63 ++++++++++ packages/spec/src/migrations/registry.ts | 89 ++++++++++++++ .../src/type-alias-convention.pin.test.ts | 17 ++- 10 files changed, 331 insertions(+), 145 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.api__FlowSummary.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsRequest.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsResponse.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.automation-flow-list-route-retired.ts 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..ba31ae55a12 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` were retired under #19543 (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 648a6cdf273..6c8d6d8ff0d 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2936,15 +2936,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..b5d7edeb96b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsRequest.ts @@ -0,0 +1,14 @@ +// 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 #8715 route-3 shape). +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..1fe8b4efcc9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.automation-flow-list-route-retired.ts @@ -0,0 +1,63 @@ +// 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: the path simply stops being mounted. 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 /api/v1/automation (and its ' + + 'environment-scoped twin) is no longer mounted and answers the standard unmatched-route ' + + '404, with no residual refusal text — the same answer a path that never existed gets. A ' + + 'transport that forwards every automation path to the dispatcher answers the dispatcher\'s ' + + 'own route-not-found 404 for it, and 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 c92e1d2c3ba..c0f8e396b9a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5971,6 +5971,65 @@ 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: the path simply stops being mounted. 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 /api/v1/automation (and its ' + + 'environment-scoped twin) is no longer mounted and answers the standard unmatched-route ' + + '404, with no residual refusal text — the same answer a path that never existed gets. A ' + + 'transport that forwards every automation path to the dispatcher answers the dispatcher\'s ' + + 'own route-not-found 404 for it, and 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 @@ -18072,6 +18131,16 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // `GeneratedEndpointSchema.operation` still reads it. See // `retired-keys/18.api__CrudEndpointsConfig__patterns.ts` for the retirement record. 'api/CrudEndpointPattern', + // #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', // #13823 — `api/HandlerStatus` (the `implemented` / `stub` / `planned` enum) // left with its two carriers: `RestApiEndpoint.handlerStatus` is tombstoned // in this same major (`RETIRED_KEYS_BY_MAJOR[18]`) and @@ -18082,6 +18151,26 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // objectstack, objectui (pinned sha) or cloud. See // `18.api__RestApiEndpoint__handlerStatus.ts` for the retirement record. 'api/HandlerStatus', + // #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 #8715 route-3 shape). + '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 8debed6e2f9..9e5701752ef 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'; // --------------------------------------------------------------------------- -// 790 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 789 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 > >>; @@ -1662,7 +1661,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 790 isomorphic pins', () => { + it('still declares all 789 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 @@ -2285,7 +2284,17 @@ describe('ADR-0122 type-alias convention', () => { // release carried it: `KanbanConfigSchema` loses the `limit` whose applied // default had split its two shapes, `KanbanConfigParsed` is deleted and the // schema is re-pinned as Iso_ui_view__KanbanConfigSchema. +1 added. - expect(pins).toHaveLength(790); + // + // 790 -> 789 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. -1 removed. + expect(pins).toHaveLength(789); // 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 From f6de63b723c01fbf1664eee8cac5dadd72fb693e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 02:51:37 +0000 Subject: [PATCH 02/11] chore(spec): regenerate the artifacts the list-door retirement ratchets json-schema.manifest, authorable-surface and authorable-defaults drop the three retired defs (whole-def removals, path 3); api-surface, export-origins, declaration-map, the references docs and the strictness-ledger counts are regenerated with check:generated --fix; ListAiConversationsResponse:hasMore joins the authorable surface. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .../docs/references/api/automation-api.mdx | 89 ++----------------- content/docs/references/api/protocol.mdx | 5 +- content/docs/references/index.mdx | 10 +-- ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/spec/api-surface/api.json | 8 -- packages/spec/authorable-defaults/api.json | 1 - packages/spec/authorable-surface/api.json | 17 +--- packages/spec/declaration-map/api.json | 6 -- packages/spec/export-origins/api.json | 8 -- packages/spec/json-schema.manifest/api.json | 3 - 10 files changed, 18 insertions(+), 131 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 8864e444ede..1cab4c51f63 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` were retired under #19543 (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 e578e76d0bf..b26d393e840 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 d49933c191c..f73d998846a 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 — 1538 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1535 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 | 444 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 441 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 75 | 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** | **1538** | 14 protocol modules | +| **Total** | **196** | **1535** | 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, 444 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 441 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 57e83b94524..f9ed77810f9 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/` | 454 | +| `api/` | 451 | | `identity/` | 32 | | `integration/` | 8 | | `kernel/` | 247 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index f2fe07b5ee2..9d37c35d10f 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -414,8 +414,6 @@ "FindReferencesToMetaResponse (type)", "FindReferencesToMetaResponseParsed (type)", "FindReferencesToMetaResponseSchema (const)", - "FlowSummary (type)", - "FlowSummarySchema (const)", "GeneratedApiDocumentation (type)", "GeneratedApiDocumentationParsed (type)", "GeneratedApiDocumentationSchema (const)", @@ -590,12 +588,6 @@ "ListExportJobsResponse (type)", "ListExportJobsResponseParsed (type)", "ListExportJobsResponseSchema (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 b0b39dc1982..590842b7fdc 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -88,7 +88,6 @@ "api/InitiateChunkedUploadRequest:chunkSize = 5242880", "api/InitiateChunkedUploadRequest:scope = \"user\"", "api/ListExportJobsRequest:limit = 20", - "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 dcfe6ac3aef..c6dc3b980bc 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -710,14 +710,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", @@ -1017,6 +1009,7 @@ "api/ListAiConversationsRequest:cursor", "api/ListAiConversationsRequest:limit", "api/ListAiConversationsResponse:conversations", + "api/ListAiConversationsResponse:hasMore", "api/ListAiPendingActionsRequest:conversationId", "api/ListAiPendingActionsRequest:limit", "api/ListAiPendingActionsRequest:status", @@ -1031,14 +1024,6 @@ "api/ListExportJobsResponse:error", "api/ListExportJobsResponse:meta", "api/ListExportJobsResponse:success", - "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 1ec93321851..6853572bdd8 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -296,8 +296,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", @@ -436,10 +434,6 @@ "ListExportJobsRequestSchema": "api/ListExportJobsRequest", "ListExportJobsResponse": "api/ListExportJobsResponse", "ListExportJobsResponseSchema": "api/ListExportJobsResponse", - "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 dc2f25bd619..ef3ff237e9c 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -392,8 +392,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)", @@ -564,12 +562,6 @@ "ListExportJobsResponse": "src/api/export.zod.ts#ListExportJobsResponse (type)", "ListExportJobsResponseParsed": "src/api/export.zod.ts#ListExportJobsResponseParsed (type)", "ListExportJobsResponseSchema": "src/api/export.zod.ts#ListExportJobsResponseSchema (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 237945c8387..17919145426 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -163,7 +163,6 @@ "api/FindDataRequest", "api/FindDataResponse", "api/FindReferencesToMetaResponse", - "api/FlowSummary", "api/GeneratedApiDocumentation", "api/GeneratedEndpoint", "api/GetAnalyticsMetaRequest", @@ -240,8 +239,6 @@ "api/ListDraftsResponse", "api/ListExportJobsRequest", "api/ListExportJobsResponse", - "api/ListFlowsRequest", - "api/ListFlowsResponse", "api/ListImportJobsRequest", "api/ListImportJobsResponse", "api/ListInstalledPackagesRequest", From 0be04462dd1e143326d2a75cc8bf51ce0b8e6495 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:04:29 +0000 Subject: [PATCH 03/11] feat(runtime,client)!: unmount GET /api/v1/automation and remove client.automation.list Door 4 of the list-door card: the dispatcher no longer mounts GET at the bare automation path and the domain keeps no GET / branch, the route-ledger row and the SDK method go with it, and every test that used the list as a convenient probe now reads GET /automation/_status. The socket test pins the wire answer (the host's 405 + Allow: POST, byte-identical to a POST-only control path); docs, the platform checklist, the authz census row and the changeset follow. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .changeset/19543-list-doors-3-4.md | 64 +++++++++++++++++++ content/docs/api/client-sdk.mdx | 4 +- content/docs/api/plugin-endpoints.mdx | 6 +- .../areas/access-security.json | 4 +- packages/client/src/client.test.ts | 28 ++++---- packages/client/src/index.ts | 14 ++-- .../test/authz-probe-blind-spot.census.ts | 10 ++- ...se-anonymous-deny-surfaces.dogfood.test.ts | 20 +++--- ...-plugin.anonymous-gate.integration.test.ts | 51 ++++++++++++++- packages/runtime/src/dispatcher-plugin.ts | 17 +++-- .../src/domain-handler-registry.test.ts | 39 +++++++++-- .../anonymous-gate-actions-automation.test.ts | 36 +++++++++-- .../automation-write-capability-gate.test.ts | 8 +-- packages/runtime/src/domains/automation.ts | 49 ++++++++------ ...-dispatcher.tenancy-posture-outage.test.ts | 9 ++- packages/runtime/src/http-dispatcher.test.ts | 54 ++++++++++------ packages/runtime/src/route-ledger.ts | 3 +- .../services/service-automation/README.md | 4 +- packages/spec/src/api/automation-api.zod.ts | 4 +- .../18.automation-flow-list-route-retired.ts | 17 +++-- packages/spec/src/migrations/registry.ts | 17 +++-- 21 files changed, 338 insertions(+), 120 deletions(-) create mode 100644 .changeset/19543-list-doors-3-4.md diff --git a/.changeset/19543-list-doors-3-4.md b/.changeset/19543-list-doors-3-4.md new file mode 100644 index 00000000000..8cbb46268cc --- /dev/null +++ b/.changeset/19543-list-doors-3-4.md @@ -0,0 +1,64 @@ +--- +'@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 answers `404 ROUTE_NOT_FOUND`. 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/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-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index f96645669b7..fe85a0e024c 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -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..4a9fb6fd19b 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -645,11 +645,38 @@ 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 — ROUTE_NOT_FOUND, 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()` answers its standard 404 — the + // same answer as a path that never existed — even though the service + // still offers `listFlows` (it is 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.handled).toBe(true); + expect(result.response?.status).toBe(404); + expect(result.response?.body?.error?.code).toBe('ROUTE_NOT_FOUND'); + expect(listFlows).not.toHaveBeenCalled(); + + // …the same answer a path this domain never had gets, modulo the echoed + // path itself — no retirement-specific refusal text, code or hint. + const never = await dispatcher.dispatch('GET', '/zz-never-mounted', undefined, {}, {} as any); + expect(never.response?.status).toBe(404); + const echoless = (body: any, path: string) => + JSON.stringify(body).split(path).join(''); + expect(echoless(result.response?.body, '/automation')) + .toBe(echoless(never.response?.body, '/zz-never-mounted')); + + // 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 +696,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-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..c5d1ca37d35 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`, + * the dispatcher's ROUTE_NOT_FOUND. 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,31 @@ 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, and `dispatch()` answers its + // standard 404 ROUTE_NOT_FOUND. 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 + // "the path does not exist", the same answer as one never mounted. // - // [#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..fc7a3b23ae5 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -351,10 +351,21 @@ 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 dispatcher's ROUTE_NOT_FOUND, pinned through + // the real `dispatch()` in `domain-handler-registry.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 +1335,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 +1359,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 +1414,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 +1467,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'); }); @@ -3342,7 +3356,7 @@ describe('HttpDispatcher', () => { const stub = stubbed({ execute: vi.fn().mockResolvedValue({ success: true, output: undefined, durationMs: 0 }), trigger: vi.fn().mockResolvedValue({ success: true }), - listFlows: vi.fn().mockResolvedValue([]), + getFlowRuntimeStates: vi.fn().mockReturnValue([]), registerFlow: vi.fn(), }); serveOnly('automation', stub); @@ -3358,8 +3372,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 +3386,17 @@ describe('HttpDispatcher', () => { } expect(stub.execute).not.toHaveBeenCalled(); expect(stub.trigger).not.toHaveBeenCalled(); - expect(stub.listFlows).not.toHaveBeenCalled(); + expect(stub.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..cc9c7b5a9a1 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -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/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/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index ba31ae55a12..c4d86b26972 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -22,8 +22,8 @@ import { ExecutionLogSchema, ExecutionStatus, FlowRunSummarySchema } from '../au * 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` were retired under #19543 (ADR-0087 semantic - * entry `automation-flow-list-route-retired`). + * and `client.automation.list` are retired (ADR-0087 semantic entry + * `automation-flow-list-route-retired`). * * @example Endpoints * ``` 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 index 1fe8b4efcc9..0680d9f0c74 100644 --- 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 @@ -39,18 +39,21 @@ export const entry: SemanticMigration = { + '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: the path simply stops being mounted. There is ' + + '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 /api/v1/automation (and its ' - + 'environment-scoped twin) is no longer mounted and answers the standard unmatched-route ' - + '404, with no residual refusal text — the same answer a path that never existed gets. A ' - + 'transport that forwards every automation path to the dispatcher answers the dispatcher\'s ' - + 'own route-not-found 404 for it, and the domain\'s anonymous floor still answers an ' - + 'unidentified caller 401 first, as it does for every automation path. The automation ' + '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 answers the dispatcher\'s own route-not-found 404 for it, 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 ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c0f8e396b9a..9383b4621e6 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6008,18 +6008,21 @@ const step18: MigrationStep = { + '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: the path simply stops being mounted. There is ' + + '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 /api/v1/automation (and its ' - + 'environment-scoped twin) is no longer mounted and answers the standard unmatched-route ' - + '404, with no residual refusal text — the same answer a path that never existed gets. A ' - + 'transport that forwards every automation path to the dispatcher answers the dispatcher\'s ' - + 'own route-not-found 404 for it, and the domain\'s anonymous floor still answers an ' - + 'unidentified caller 401 first, as it does for every automation path. The automation ' + '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 answers the dispatcher\'s own route-not-found 404 for it, 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 ' From bca59830c58da2f49eb25a335fa650cb31dac384 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:22:45 +0000 Subject: [PATCH 04/11] fix(runtime): state the retired list route's real wire answers dispatch() hands a declined domain path back as handled: false, so the transport answers: the dispatcher plugin never mounts GET at the bare path (Hono then answers 405 + Allow: POST) and a catch-all adapter answers its own 404. Pins, comments, the ADR-0087 entry and the changeset now say exactly that. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .changeset/19543-list-doors-3-4.md | 3 ++- .../src/domain-handler-registry.test.ts | 26 ++++++++----------- packages/runtime/src/domains/automation.ts | 21 ++++++++------- packages/runtime/src/http-dispatcher.test.ts | 5 ++-- .../18.automation-flow-list-route-retired.ts | 7 ++--- packages/spec/src/migrations/registry.ts | 7 ++--- 6 files changed, 36 insertions(+), 33 deletions(-) diff --git a/.changeset/19543-list-doors-3-4.md b/.changeset/19543-list-doors-3-4.md index 8cbb46268cc..ba2b6ca0b8f 100644 --- a/.changeset/19543-list-doors-3-4.md +++ b/.changeset/19543-list-doors-3-4.md @@ -26,7 +26,8 @@ FROM → TO, per surface: 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 answers `404 ROUTE_NOT_FOUND`. Fix: read `GET /api/v1/meta/flow`; + 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. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 4a9fb6fd19b..a6701a33bd3 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -645,30 +645,26 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { */ const auth = { api: { getSession: async () => ({ user: { id: 'u_test' } }) } }; - it('[#19543] GET /automation is RETIRED — ROUTE_NOT_FOUND, and the flow-name enumeration is never called', async () => { + 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()` answers its standard 404 — the - // same answer as a path that never existed — even though the service - // still offers `listFlows` (it is an engine method, not a route). + // 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.handled).toBe(true); - expect(result.response?.status).toBe(404); - expect(result.response?.body?.error?.code).toBe('ROUTE_NOT_FOUND'); + expect(result).toEqual({ handled: false }); expect(listFlows).not.toHaveBeenCalled(); - // …the same answer a path this domain never had gets, modulo the echoed - // path itself — no retirement-specific refusal text, code or hint. - const never = await dispatcher.dispatch('GET', '/zz-never-mounted', undefined, {}, {} as any); - expect(never.response?.status).toBe(404); - const echoless = (body: any, path: string) => - JSON.stringify(body).split(path).join(''); - expect(echoless(result.response?.body, '/automation')) - .toBe(echoless(never.response?.body, '/zz-never-mounted')); + // …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 diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index c5d1ca37d35..9ae9eb04fa9 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -1823,10 +1823,10 @@ export async function classifyResumeResult( * Routes: * GET / → RETIRED (#19543, door ④ — 「退役,统一走 * /meta/flow」): no branch here, so the - * request falls through to `handled: false`, - * the dispatcher's ROUTE_NOT_FOUND. Flows are - * metadata (ADR-0106); list them with - * `GET /api/v1/meta/flow` + * 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 @@ -2048,11 +2048,14 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // 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, and `dispatch()` answers its - // standard 404 ROUTE_NOT_FOUND. 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 - // "the path does not exist", the same answer as one never mounted. + // 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 — the surviving definition reads stay authenticated-only, // with a reason] `GET /:name`, `GET /actions`, `GET /connectors` and diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index fc7a3b23ae5..b6f6b00f7f0 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -353,8 +353,9 @@ describe('HttpDispatcher', () => { // [#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 dispatcher's ROUTE_NOT_FOUND, pinned through - // the real `dispatch()` in `domain-handler-registry.test.ts`), and the + // `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 () => { 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 index 0680d9f0c74..b63a91ab13a 100644 --- 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 @@ -51,9 +51,10 @@ export const entry: SemanticMigration = { + '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 answers the dispatcher\'s own route-not-found 404 for it, and there the ' - + 'domain\'s anonymous floor still answers an unidentified caller 401 first, as it does for ' - + 'every automation path. The automation ' + + '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 ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9383b4621e6..95d309fc713 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6020,9 +6020,10 @@ const step18: MigrationStep = { + '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 answers the dispatcher\'s own route-not-found 404 for it, and there the ' - + 'domain\'s anonymous floor still answers an unidentified caller 401 first, as it does for ' - + 'every automation path. The automation ' + + '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 ' From b19cbccfc14a117693e3babf68f020f0a13bcf84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:35:33 +0000 Subject: [PATCH 05/11] test(runtime): hold the stub-slot mock as a typed local; the debt ledger shrinks by one Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- packages/runtime/src/http-dispatcher.test.ts | 8 ++++++-- packages/runtime/test-typecheck-debt.json | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index b6f6b00f7f0..79ee9a5d406 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -3354,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 }), - getFlowRuntimeStates: vi.fn().mockReturnValue([]), + getFlowRuntimeStates, registerFlow: vi.fn(), }); serveOnly('automation', stub); @@ -3387,7 +3391,7 @@ describe('HttpDispatcher', () => { } expect(stub.execute).not.toHaveBeenCalled(); expect(stub.trigger).not.toHaveBeenCalled(); - expect(stub.getFlowRuntimeStates).not.toHaveBeenCalled(); + expect(getFlowRuntimeStates).not.toHaveBeenCalled(); expect(stub.registerFlow).not.toHaveBeenCalled(); }); 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, From befb186a0e020216fa5cb2d3810505dc972e7fa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:40:34 +0000 Subject: [PATCH 06/11] chore(spec): regenerate the automation-api reference after the docblock edit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- content/docs/references/api/automation-api.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 1cab4c51f63..733bfa2a15e 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -21,8 +21,8 @@ The wire paths the platform serves: the dispatcher mounts this door at its 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` were retired under #19543 (ADR-0087 semantic -entry `automation-flow-list-route-retired`). +and `client.automation.list` are retired (ADR-0087 semantic entry +`automation-flow-list-route-retired`). **Endpoints** ``` From c875962bced60896dc89f2eed00f84272ae678ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:55:49 +0000 Subject: [PATCH 07/11] test(runtime): the #7900 audit table drops the retired GET / row Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .../src/domains/automation-run-read-permission-gate.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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' }, From c62d82e72c7fc1b30fbe517333fafebe7d08bb01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:44:46 +0000 Subject: [PATCH 08/11] chore(runtime): the route-ledger census sentence reads 81 rows check:route-ledger-census --fix, after the GET /automation row left the ledger deliberately. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- packages/runtime/src/route-ledger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index cc9c7b5a9a1..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 From c69a8c963136bb2b5bb90f7085d44ec03d7a5aea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:45:11 +0000 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20the=20retired=20flow=20list's=20tw?= =?UTF-8?q?o=20CI=20reds=20=E2=80=94=20a=20citation=20that=20resolves,=20a?= =?UTF-8?q?nd=20every=2082-row=20ledger=20figure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retired-defs/18.api__ListFlowsRequest.ts cited #8715, which never resolved; it now names the precedent by its ADR-0087 entry id (package-rollback-response-retired). Registry regenerated. - The runtime route ledger holds 81 rows over 21 domains (derived from the table, domains unchanged): the #17111-pinned figure in authz-conformance.matrix.ts, the census reading in authz-probe-blind-spot.census.ts and the dated note in authz-ledger-population.baseline.ts now say so; lint.yml's comment on the census gate states its 26-of-82 reading as the one taken when the gate landed. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .github/workflows/lint.yml | 5 +++-- packages/qa/dogfood/test/authz-conformance.matrix.ts | 2 +- packages/qa/dogfood/test/authz-ledger-population.baseline.ts | 5 +++-- packages/qa/dogfood/test/authz-probe-blind-spot.census.ts | 2 +- .../entries/retired-defs/18.api__ListFlowsRequest.ts | 4 +++- packages/spec/src/migrations/registry.ts | 4 +++- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c168ee6d449..783fdeea847 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3586,8 +3586,9 @@ jobs: # exited 0; it was caught only because an unrelated gate happened to redden. # The file's own package suite is domain-level ("every registered dispatcher # domain has at least one ledger entry"), which a shorter list satisfies, and - # the cross-ledger guards compare against a UNION that still holds 26 of the - # 82 rows' wire patterns from other ledgers. This gate holds the GENERATED + # the cross-ledger guards compare against a UNION that held 26 of the then + # 82 rows' wire patterns from other ledgers when this gate landed (the + # dated reading is in the script's header). This gate holds the GENERATED # census sentence above the array to the array's real length, so a deletion # reds and a deliberate change shows its digits moving in the same diff. # Full-repo state, not diff-shaped, so it runs its real check unconditionally. 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 fe85a0e024c..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 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 index b5d7edeb96b..a0e7f07c95a 100644 --- a/packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsRequest.ts +++ b/packages/spec/src/migrations/entries/retired-defs/18.api__ListFlowsRequest.ts @@ -10,5 +10,7 @@ // 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 #8715 route-3 shape). +// 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/registry.ts b/packages/spec/src/migrations/registry.ts index 31fceb7758a..144d4ca1a40 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -18213,7 +18213,9 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // 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 #8715 route-3 shape). + // 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`, From 3b8a66d6384f0c5def5a22460f0c91699f628b91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 08:28:31 +0000 Subject: [PATCH 10/11] chore(spec): regenerate the artifacts on the merged tree (main with #20194) os-regen-merge.sh took main's side of every generated path both sides moved; this commit re-derives them from the merged sources. The two hand deletions a generator cannot reproduce (json-schema.manifest/api.json -3, authorable-surface/api.json -16, the whole-def removals of api/FlowSummary, api/ListFlowsRequest, api/ListFlowsResponse) are re-applied on top of main's bytes; registry.ts is regenerated from both sides' entries (+95 lines, no deletions); check:generated --fix rebuilt spec and rewrote the five it proved stale. Delta vs origin/main is exactly this PR's: the three retired defs out, ListAiConversationsResponse:hasMore in, strictness-ledger api/ 435 -> 432. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- content/docs/references/api/protocol.mdx | 6 +- content/docs/references/index.mdx | 16 ++-- ...07-unknown-key-strictness-ledger.counts.md | 24 ++--- packages/spec/api-surface/api.json | 34 ------- packages/spec/authorable-defaults/api.json | 7 -- packages/spec/authorable-surface/api.json | 67 ------------- packages/spec/declaration-map/api.json | 23 ----- packages/spec/export-origins/api.json | 33 ------- packages/spec/json-schema.manifest/api.json | 12 --- packages/spec/src/migrations/registry.ts | 95 +++++++++++++++++++ 10 files changed, 118 insertions(+), 199 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index b26d393e840..0cf97a05eb0 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1216,7 +1216,7 @@ Enable package response | **name** | `string` | ✅ | Item name | | **cacheRequest** | `{ ifNoneMatch?: string; ifModifiedSince?: string; cacheControl?: object }` | optional | Cache validation parameters | | **locale** | `string` | optional | Resolved response locale. Folded into the ETag so a language switch never returns a stale-locale 304 — metadata is translated *after* the cache validator check (issue). | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. Selects the org partition in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — exactly as on the uncached read. Also folded into the ETag, so a scope switch never returns a stale 304 from another scope's cached representation. Absent = environment-wide read. | +| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — exactly as on the uncached read. Also folded into the ETag, so a scope switch never returns a stale 304 from another scope's cached representation. Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide. | ### Nested Shape: `GetMetaItemCachedRequest.cacheRequest` @@ -1270,7 +1270,7 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | | **packageId** | `string` | optional | Optional package ID — scopes the `code` layer so a same-name collision resolves to the requested package's artifact (ADR-0048). | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. Selects the org partition in the ADR-0005 overlay read order, so it decides which tenant's customization row is reported as the `overlay` layer (and merged into `effective`). Absent = environment-wide read: `overlay` reports the env-level row only. | +| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order, so it decides which tenant's customization row is reported as the `overlay` layer (and merged into `effective`). Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide: `overlay` reports the env-level row only. | --- @@ -1319,7 +1319,7 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name (snake_case identifier) | | **packageId** | `string` | optional | Optional package ID to filter items by | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. Selects the org partition in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization row is served as the item. Absent = environment-wide read: only env-level overlays apply and no org partition is consulted. | +| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization row is served as the item. Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide and only env-level overlays apply. | | **state** | `Enum<'active' \| 'draft'>` | optional | Draft-visibility switch — which lifecycle row to read (strict mode): `'draft'` opens the pending draft buffer (Studio's editor read) and fails when no draft exists; absent or `'active'` reads the live published row. Distinct from `previewDrafts`, which FALLS BACK to the active row when no draft exists. Declaration ≠ authorization: this member only selects which stored row is read — ADR-0106 masking is unaffected, and draft access is gated upstream, not by this schema. | | **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview, non-strict): when true and `state` is not `'draft'`, a pending draft row is preferred if one exists, else the read falls back to the active row — the render path degrades to the published value instead of erroring. A served draft is tagged `_draft: true` so UIs can badge it. Declaration ≠ authorization: this member only switches which row is read, and ADR-0106 masking is unaffected — draft preview is admin-gated upstream, not by this schema. | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index f73d998846a..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 — 1535 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,8 +20,8 @@ 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 | 441 | REST contracts, endpoints, routing, realtime, batch, discovery. | -| [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [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. | | [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | @@ -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** | **1535** | 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, 441 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 429 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -81,7 +81,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`error-code-ledger.zod.ts`](/docs/references/api/error-code-ledger) | `ErrorCode`, `ProvenanceWaiver`, `StandardSynonymWaiver` | | [`errors.zod.ts`](/docs/references/api/errors) | `EnhancedApiError`, `ErrorCategory`, `ErrorResponse`, `FieldError`, `FieldErrorCode`, `RetryStrategy`, `StandardErrorCode` | | [`events.zod.ts`](/docs/references/api/events) | `BulkDataEvent`, `BulkDataEventType`, `DataEvent`, `DataEventType`, `MetadataEvent`, `MetadataEventType` | -| [`export.zod.ts`](/docs/references/api/export) | `CreateExportJobRequest`, `CreateExportJobResponse`, `CreateImportJobRequest`, `CreateImportJobResponse`, `DeduplicationStrategy`, `ExportFormat`, `ExportImportTemplate`, `ExportJobProgress`, `ExportJobStatus`, `ExportJobSummary`, `FieldMappingEntry`, `GetExportJobDownloadRequest`, `GetExportJobDownloadResponse`, `ImportJobProgress`, `ImportJobResults`, `ImportJobStatus`, `ImportJobSummary`, `ImportMapping`, `ImportRequest`, `ImportResponse`, `ImportRowResult`, `ImportValidationConfig`, `ImportValidationMode`, `ImportValidationResult`, `ImportWriteMode`, `ListExportJobsRequest`, `ListExportJobsResponse`, `ListImportJobsRequest`, `ListImportJobsResponse`, `ScheduleExportRequest`, `ScheduleExportResponse`, `ScheduledExport`, `UndoImportJobResponse` | +| [`export.zod.ts`](/docs/references/api/export) | `CreateImportJobRequest`, `CreateImportJobResponse`, `DeduplicationStrategy`, `ExportFormat`, `ExportImportTemplate`, `FieldMappingEntry`, `ImportJobProgress`, `ImportJobResults`, `ImportJobStatus`, `ImportJobSummary`, `ImportMapping`, `ImportRequest`, `ImportResponse`, `ImportRowResult`, `ImportValidationConfig`, `ImportValidationMode`, `ImportValidationResult`, `ImportWriteMode`, `ListImportJobsRequest`, `ListImportJobsResponse`, `UndoImportJobResponse` | | [`http-cache.zod.ts`](/docs/references/api/http-cache) | `CacheControl`, `CacheDirective`, `CacheInvalidationRequest`, `CacheInvalidationResponse`, `CacheInvalidationTarget`, `ETag`, `MetadataCacheRequest`, `MetadataCacheResponse` | | [`metadata.zod.ts`](/docs/references/api/metadata) | `AppDefinitionResponse`, `ConceptListResponse`, `MetadataBulkRegisterRequest`, `MetadataBulkResponse`, `MetadataBulkUnregisterRequest`, `MetadataDeleteResponse`, `MetadataDependenciesResponse`, `MetadataDependentsResponse`, `MetadataExistsResponse`, `MetadataExportRequest`, `MetadataExportResponse`, `MetadataImportRequest`, `MetadataImportResponse`, `MetadataItemResponse`, `MetadataListResponse`, `MetadataNamesResponse`, `MetadataQueryRequest`, `MetadataQueryResponse`, `MetadataRegisterRequest`, `MetadataTypeInfoResponse`, `MetadataTypesResponse`, `MetadataValidateRequest`, `MetadataValidateResponse`, `ObjectDefinitionResponse` | | [`misc`](/docs/references/api/misc) *(no single source file)* | `ResolvedBook`, `ResolvedEntry`, `ResolvedGroup` | @@ -105,7 +105,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. ## Automation Protocol -**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 75 schemas** +**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 74 schemas** Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. @@ -115,7 +115,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` | | [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `EndConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` | | [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` | -| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` | +| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary` | | [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` | | [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect`, `FlowFunctionLoweredDeclaration` | | [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig` | 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 f9ed77810f9..215c2d1d4a7 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,8 +21,8 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 453 | -| Still-open (strip) sites | 126 | +| Object sites in them | 452 | +| Still-open (strip) sites | 125 | | Files carrying at least one | 22 | Remaining strip sites by class: @@ -31,7 +31,7 @@ Remaining strip sites by class: |---|---| | authorable — the ruling's forced scope | 1 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 121 | +| wire / open — out of forced scope | 120 | | no door — no carrier, ADR-0049 territory | 3 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 1 | @@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th |---|---|---|---|---|---| | `ui/` | 179 | 169 | 3 | 0 | 7 | | `data/` | 159 | 76 | 1 | 0 | 82 | -| `automation/` | 68 | 43 | 0 | 1 | 24 | +| `automation/` | 67 | 43 | 0 | 1 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **453** | **322** | **4** | **1** | **126** | +| **total** | **452** | **322** | **4** | **1** | **125** | ## File-level triage — site counts @@ -117,7 +117,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `bpmn-interop.zod.ts` | 5 | | `builtin-node-config.zod.ts` | 10 | | `control-flow.zod.ts` | 6 | -| `execution.zod.ts` | 13 | +| `execution.zod.ts` | 12 | | `flow-function.zod.ts` | 1 | | `flow.zod.ts` | 11 | | `io-node-config.zod.ts` | 2 | @@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `state-machine.zod.ts` | 6 | | `time-relative-trigger.zod.ts` | 1 | | `webhook.zod.ts` | 1 | -| **total** | **68** | +| **total** | **67** | ### `security/` — sites @@ -204,22 +204,22 @@ over it is here. ### `automation/` — open -**24 strip of 68**, in 5 file(s). +**23 strip of 67**, in 5 file(s). | File | Strip | Sites | |---|---|---| | `bpmn-interop.zod.ts` | 5 | 5 | | `control-flow.zod.ts` | 1 | 6 | -| `execution.zod.ts` | 13 | 13 | +| `execution.zod.ts` | 12 | 12 | | `flow.zod.ts` | 1 | 11 | | `node-executor.zod.ts` | 4 | 4 | -| **total** | **24** | **68** | +| **total** | **23** | **67** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 0 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 24 | +| wire / open — out of forced scope | 23 | | no door — no carrier, ADR-0049 territory | 0 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 0 | @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 78 | -| `api/` | 451 | +| `api/` | 432 | | `identity/` | 32 | | `integration/` | 8 | | `kernel/` | 247 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 9d37c35d10f..7ae6754898f 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -199,12 +199,6 @@ "CreateDataRequestSchema (const)", "CreateDataResponse (type)", "CreateDataResponseSchema (const)", - "CreateExportJobRequest (type)", - "CreateExportJobRequestParsed (type)", - "CreateExportJobRequestSchema (const)", - "CreateExportJobResponse (type)", - "CreateExportJobResponseParsed (type)", - "CreateExportJobResponseSchema (const)", "CreateFlowRequest (type)", "CreateFlowRequestParsed (type)", "CreateFlowRequestSchema (const)", @@ -371,19 +365,11 @@ "EventPatternSchema (const)", "EventSubscription (type)", "EventSubscriptionSchema (const)", - "ExportApiContracts (const)", "ExportFormat (const)", "ExportFormat (type)", "ExportImportTemplate (type)", "ExportImportTemplateParsed (type)", "ExportImportTemplateSchema (const)", - "ExportJobProgress (type)", - "ExportJobProgressParsed (type)", - "ExportJobProgressSchema (const)", - "ExportJobStatus (const)", - "ExportJobStatus (type)", - "ExportJobSummary (type)", - "ExportJobSummarySchema (const)", "ExportRequest (type)", "ExportRequestParsed (type)", "ExportRequestSchema (const)", @@ -436,11 +422,6 @@ "GetEffectivePermissionsRequestSchema (const)", "GetEffectivePermissionsResponse (type)", "GetEffectivePermissionsResponseSchema (const)", - "GetExportJobDownloadRequest (type)", - "GetExportJobDownloadRequestSchema (const)", - "GetExportJobDownloadResponse (type)", - "GetExportJobDownloadResponseParsed (type)", - "GetExportJobDownloadResponseSchema (const)", "GetFieldLabelsRequest (type)", "GetFieldLabelsRequestSchema (const)", "GetFieldLabelsResponse (type)", @@ -582,12 +563,6 @@ "ListDraftsResponse (type)", "ListDraftsResponseParsed (type)", "ListDraftsResponseSchema (const)", - "ListExportJobsRequest (type)", - "ListExportJobsRequestParsed (type)", - "ListExportJobsRequestSchema (const)", - "ListExportJobsResponse (type)", - "ListExportJobsResponseParsed (type)", - "ListExportJobsResponseSchema (const)", "ListImportJobsRequest (type)", "ListImportJobsRequestParsed (type)", "ListImportJobsRequestSchema (const)", @@ -935,15 +910,6 @@ "SaveMetaItemRequestSchema (const)", "SaveMetaItemResponse (type)", "SaveMetaItemResponseSchema (const)", - "ScheduleExportRequest (type)", - "ScheduleExportRequestParsed (type)", - "ScheduleExportRequestSchema (const)", - "ScheduleExportResponse (type)", - "ScheduleExportResponseParsed (type)", - "ScheduleExportResponseSchema (const)", - "ScheduledExport (type)", - "ScheduledExportParsed (type)", - "ScheduledExportSchema (const)", "SearchAllHit (type)", "SearchAllHitSchema (const)", "SearchAllPageHit (type)", diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index 590842b7fdc..db27d77140f 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -38,9 +38,6 @@ "api/BatchOptions:returnRecords = false", "api/BulkRequest:allOrNone = true", "api/CacheInvalidationRequest:cascade = false", - "api/CreateExportJobRequest:encoding = \"utf-8\"", - "api/CreateExportJobRequest:format = \"csv\"", - "api/CreateExportJobRequest:includeHeaders = true", "api/CreateFlowRequest:runAs = \"user\"", "api/CreateFlowRequest:status = \"draft\"", "api/CreateFlowRequest:version = 1", @@ -87,7 +84,6 @@ "api/ImportValidationConfig:trimWhitespace = true", "api/InitiateChunkedUploadRequest:chunkSize = 5242880", "api/InitiateChunkedUploadRequest:scope = \"user\"", - "api/ListExportJobsRequest:limit = 20", "api/ListImportJobsRequest:limit = 50", "api/ListImportJobsRequest:offset = 0", "api/ListRunsRequest:limit = 20", @@ -172,9 +168,6 @@ "api/RouteDefinition:public = false", "api/RouterConfig:basePath = \"/api\"", "api/RouterConfig:mounts = {\"ai\":\"/ai\",\"analytics\":\"/analytics\",\"auth\":\"/auth\",\"automation\":\"/automation\",\"data\":\"/data\",\"i18n\":\"/i18n\",\"metadata\":\"/meta\",\"notifications\":\"/notifications\",\"packages\":\"/packages\",\"realtime\":\"/realtime\",\"storage\":\"/storage\",\"ui\":\"/ui\"}", - "api/ScheduleExportRequest:format = \"csv\"", - "api/ScheduledExport:enabled = true", - "api/ScheduledExport:format = \"csv\"", "api/SessionUser:emailVerified = false", "api/SessionUser:roles = []", "api/VersioningConfig:headerName = \"ObjectStack-Version\"", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index c6dc3b980bc..b5bb42f1c97 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -353,19 +353,6 @@ "api/CreateDataResponse:id", "api/CreateDataResponse:object", "api/CreateDataResponse:record", - "api/CreateExportJobRequest:encoding", - "api/CreateExportJobRequest:fields", - "api/CreateExportJobRequest:filter", - "api/CreateExportJobRequest:format", - "api/CreateExportJobRequest:includeHeaders", - "api/CreateExportJobRequest:limit", - "api/CreateExportJobRequest:object", - "api/CreateExportJobRequest:sort", - "api/CreateExportJobRequest:templateId", - "api/CreateExportJobResponse:data", - "api/CreateExportJobResponse:error", - "api/CreateExportJobResponse:meta", - "api/CreateExportJobResponse:success", "api/CreateFlowRequest:_lock", "api/CreateFlowRequest:_lockDocsUrl", "api/CreateFlowRequest:_lockReason", @@ -661,19 +648,6 @@ "api/ExportImportTemplate:name", "api/ExportImportTemplate:object", "api/ExportImportTemplate:updatedAt", - "api/ExportJobProgress:data", - "api/ExportJobProgress:error", - "api/ExportJobProgress:meta", - "api/ExportJobProgress:success", - "api/ExportJobSummary:completedAt", - "api/ExportJobSummary:createdAt", - "api/ExportJobSummary:createdBy", - "api/ExportJobSummary:fileSize", - "api/ExportJobSummary:format", - "api/ExportJobSummary:jobId", - "api/ExportJobSummary:object", - "api/ExportJobSummary:status", - "api/ExportJobSummary:totalRecords", "api/FieldError:code", "api/FieldError:constraint", "api/FieldError:field", @@ -747,11 +721,6 @@ "api/GetDiscoveryResponse:version", "api/GetEffectivePermissionsResponse:objects", "api/GetEffectivePermissionsResponse:systemPermissions", - "api/GetExportJobDownloadRequest:jobId", - "api/GetExportJobDownloadResponse:data", - "api/GetExportJobDownloadResponse:error", - "api/GetExportJobDownloadResponse:meta", - "api/GetExportJobDownloadResponse:success", "api/GetFieldLabelsRequest:locale", "api/GetFieldLabelsRequest:object", "api/GetFieldLabelsResponse:labels", @@ -1016,14 +985,6 @@ "api/ListAiPendingActionsResponse:items", "api/ListAiPendingActionsResponse:total", "api/ListDraftsResponse:drafts", - "api/ListExportJobsRequest:cursor", - "api/ListExportJobsRequest:limit", - "api/ListExportJobsRequest:object", - "api/ListExportJobsRequest:status", - "api/ListExportJobsResponse:data", - "api/ListExportJobsResponse:error", - "api/ListExportJobsResponse:meta", - "api/ListExportJobsResponse:success", "api/ListImportJobsRequest:limit", "api/ListImportJobsRequest:object", "api/ListImportJobsRequest:offset", @@ -1589,34 +1550,6 @@ "api/SaveMetaItemResponse:state", "api/SaveMetaItemResponse:success", "api/SaveMetaItemResponse:version", - "api/ScheduleExportRequest:delivery", - "api/ScheduleExportRequest:fields", - "api/ScheduleExportRequest:filter", - "api/ScheduleExportRequest:format", - "api/ScheduleExportRequest:label", - "api/ScheduleExportRequest:name", - "api/ScheduleExportRequest:object", - "api/ScheduleExportRequest:schedule", - "api/ScheduleExportRequest:templateId", - "api/ScheduleExportResponse:data", - "api/ScheduleExportResponse:error", - "api/ScheduleExportResponse:meta", - "api/ScheduleExportResponse:success", - "api/ScheduledExport:createdAt", - "api/ScheduledExport:createdBy", - "api/ScheduledExport:delivery", - "api/ScheduledExport:enabled", - "api/ScheduledExport:fields", - "api/ScheduledExport:filter", - "api/ScheduledExport:format", - "api/ScheduledExport:id", - "api/ScheduledExport:label", - "api/ScheduledExport:lastRunAt", - "api/ScheduledExport:name", - "api/ScheduledExport:nextRunAt", - "api/ScheduledExport:object", - "api/ScheduledExport:schedule", - "api/ScheduledExport:templateId", "api/SearchAllHit:id", "api/SearchAllHit:object", "api/SearchAllHit:record", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 6853572bdd8..528d25a3240 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -151,10 +151,6 @@ "CreateDataRequestSchema": "api/CreateDataRequest", "CreateDataResponse": "api/CreateDataResponse", "CreateDataResponseSchema": "api/CreateDataResponse", - "CreateExportJobRequest": "api/CreateExportJobRequest", - "CreateExportJobRequestSchema": "api/CreateExportJobRequest", - "CreateExportJobResponse": "api/CreateExportJobResponse", - "CreateExportJobResponseSchema": "api/CreateExportJobResponse", "CreateFlowRequest": "api/CreateFlowRequest", "CreateFlowRequestSchema": "api/CreateFlowRequest", "CreateFlowResponse": "api/CreateFlowResponse", @@ -270,11 +266,6 @@ "ExportFormat": "api/ExportFormat", "ExportImportTemplate": "api/ExportImportTemplate", "ExportImportTemplateSchema": "api/ExportImportTemplate", - "ExportJobProgress": "api/ExportJobProgress", - "ExportJobProgressSchema": "api/ExportJobProgress", - "ExportJobStatus": "api/ExportJobStatus", - "ExportJobSummary": "api/ExportJobSummary", - "ExportJobSummarySchema": "api/ExportJobSummary", "ExportRequest": "api/ExportRequest", "ExportRequestSchema": "api/ExportRequest", "FieldError": "api/FieldError", @@ -316,10 +307,6 @@ "GetEffectivePermissionsRequestSchema": "api/GetEffectivePermissionsRequest", "GetEffectivePermissionsResponse": "api/GetEffectivePermissionsResponse", "GetEffectivePermissionsResponseSchema": "api/GetEffectivePermissionsResponse", - "GetExportJobDownloadRequest": "api/GetExportJobDownloadRequest", - "GetExportJobDownloadRequestSchema": "api/GetExportJobDownloadRequest", - "GetExportJobDownloadResponse": "api/GetExportJobDownloadResponse", - "GetExportJobDownloadResponseSchema": "api/GetExportJobDownloadResponse", "GetFieldLabelsRequest": "api/GetFieldLabelsRequest", "GetFieldLabelsRequestSchema": "api/GetFieldLabelsRequest", "GetFieldLabelsResponse": "api/GetFieldLabelsResponse", @@ -430,10 +417,6 @@ "ListAiPendingActionsResponseSchema": "api/ListAiPendingActionsResponse", "ListDraftsResponse": "api/ListDraftsResponse", "ListDraftsResponseSchema": "api/ListDraftsResponse", - "ListExportJobsRequest": "api/ListExportJobsRequest", - "ListExportJobsRequestSchema": "api/ListExportJobsRequest", - "ListExportJobsResponse": "api/ListExportJobsResponse", - "ListExportJobsResponseSchema": "api/ListExportJobsResponse", "ListImportJobsRequest": "api/ListImportJobsRequest", "ListImportJobsRequestSchema": "api/ListImportJobsRequest", "ListImportJobsResponse": "api/ListImportJobsResponse", @@ -678,12 +661,6 @@ "SaveMetaItemRequestSchema": "api/SaveMetaItemRequest", "SaveMetaItemResponse": "api/SaveMetaItemResponse", "SaveMetaItemResponseSchema": "api/SaveMetaItemResponse", - "ScheduleExportRequest": "api/ScheduleExportRequest", - "ScheduleExportRequestSchema": "api/ScheduleExportRequest", - "ScheduleExportResponse": "api/ScheduleExportResponse", - "ScheduleExportResponseSchema": "api/ScheduleExportResponse", - "ScheduledExport": "api/ScheduledExport", - "ScheduledExportSchema": "api/ScheduledExport", "SearchAllHit": "api/SearchAllHit", "SearchAllHitSchema": "api/SearchAllHit", "SearchAllPageHit": "api/SearchAllPageHit", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index ef3ff237e9c..15400eb0286 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -187,12 +187,6 @@ "CreateDataRequestSchema": "src/api/protocol.zod.ts#CreateDataRequestSchema (const)", "CreateDataResponse": "src/api/protocol.zod.ts#CreateDataResponse (type)", "CreateDataResponseSchema": "src/api/protocol.zod.ts#CreateDataResponseSchema (const)", - "CreateExportJobRequest": "src/api/export.zod.ts#CreateExportJobRequest (type)", - "CreateExportJobRequestParsed": "src/api/export.zod.ts#CreateExportJobRequestParsed (type)", - "CreateExportJobRequestSchema": "src/api/export.zod.ts#CreateExportJobRequestSchema (const)", - "CreateExportJobResponse": "src/api/export.zod.ts#CreateExportJobResponse (type)", - "CreateExportJobResponseParsed": "src/api/export.zod.ts#CreateExportJobResponseParsed (type)", - "CreateExportJobResponseSchema": "src/api/export.zod.ts#CreateExportJobResponseSchema (const)", "CreateFlowRequest": "src/api/automation-api.zod.ts#CreateFlowRequest (type)", "CreateFlowRequestParsed": "src/api/automation-api.zod.ts#CreateFlowRequestParsed (type)", "CreateFlowRequestSchema": "src/api/automation-api.zod.ts#CreateFlowRequestSchema (const)", @@ -352,17 +346,10 @@ "EventPatternSchema": "src/api/websocket.zod.ts#EventPatternSchema (const)", "EventSubscription": "src/api/websocket.zod.ts#EventSubscription (type)", "EventSubscriptionSchema": "src/api/websocket.zod.ts#EventSubscriptionSchema (const)", - "ExportApiContracts": "src/api/export.zod.ts#ExportApiContracts (const)", "ExportFormat": "src/api/export.zod.ts#ExportFormat (type)", "ExportImportTemplate": "src/api/export.zod.ts#ExportImportTemplate (type)", "ExportImportTemplateParsed": "src/api/export.zod.ts#ExportImportTemplateParsed (type)", "ExportImportTemplateSchema": "src/api/export.zod.ts#ExportImportTemplateSchema (const)", - "ExportJobProgress": "src/api/export.zod.ts#ExportJobProgress (type)", - "ExportJobProgressParsed": "src/api/export.zod.ts#ExportJobProgressParsed (type)", - "ExportJobProgressSchema": "src/api/export.zod.ts#ExportJobProgressSchema (const)", - "ExportJobStatus": "src/api/export.zod.ts#ExportJobStatus (type)", - "ExportJobSummary": "src/api/export.zod.ts#ExportJobSummary (type)", - "ExportJobSummarySchema": "src/api/export.zod.ts#ExportJobSummarySchema (const)", "ExportRequest": "src/api/contract.zod.ts#ExportRequest (type)", "ExportRequestParsed": "src/api/contract.zod.ts#ExportRequestParsed (type)", "ExportRequestSchema": "src/api/contract.zod.ts#ExportRequestSchema (const)", @@ -414,11 +401,6 @@ "GetEffectivePermissionsRequestSchema": "src/api/protocol.zod.ts#GetEffectivePermissionsRequestSchema (const)", "GetEffectivePermissionsResponse": "src/api/protocol.zod.ts#GetEffectivePermissionsResponse (type)", "GetEffectivePermissionsResponseSchema": "src/api/protocol.zod.ts#GetEffectivePermissionsResponseSchema (const)", - "GetExportJobDownloadRequest": "src/api/export.zod.ts#GetExportJobDownloadRequest (type)", - "GetExportJobDownloadRequestSchema": "src/api/export.zod.ts#GetExportJobDownloadRequestSchema (const)", - "GetExportJobDownloadResponse": "src/api/export.zod.ts#GetExportJobDownloadResponse (type)", - "GetExportJobDownloadResponseParsed": "src/api/export.zod.ts#GetExportJobDownloadResponseParsed (type)", - "GetExportJobDownloadResponseSchema": "src/api/export.zod.ts#GetExportJobDownloadResponseSchema (const)", "GetFieldLabelsRequest": "src/api/protocol.zod.ts#GetFieldLabelsRequest (type)", "GetFieldLabelsRequestSchema": "src/api/protocol.zod.ts#GetFieldLabelsRequestSchema (const)", "GetFieldLabelsResponse": "src/api/protocol.zod.ts#GetFieldLabelsResponse (type)", @@ -556,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)", - "ListExportJobsRequest": "src/api/export.zod.ts#ListExportJobsRequest (type)", - "ListExportJobsRequestParsed": "src/api/export.zod.ts#ListExportJobsRequestParsed (type)", - "ListExportJobsRequestSchema": "src/api/export.zod.ts#ListExportJobsRequestSchema (const)", - "ListExportJobsResponse": "src/api/export.zod.ts#ListExportJobsResponse (type)", - "ListExportJobsResponseParsed": "src/api/export.zod.ts#ListExportJobsResponseParsed (type)", - "ListExportJobsResponseSchema": "src/api/export.zod.ts#ListExportJobsResponseSchema (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)", @@ -895,15 +871,6 @@ "SaveMetaItemRequestSchema": "src/api/protocol.zod.ts#SaveMetaItemRequestSchema (const)", "SaveMetaItemResponse": "src/api/protocol.zod.ts#SaveMetaItemResponse (type)", "SaveMetaItemResponseSchema": "src/api/protocol.zod.ts#SaveMetaItemResponseSchema (const)", - "ScheduleExportRequest": "src/api/export.zod.ts#ScheduleExportRequest (type)", - "ScheduleExportRequestParsed": "src/api/export.zod.ts#ScheduleExportRequestParsed (type)", - "ScheduleExportRequestSchema": "src/api/export.zod.ts#ScheduleExportRequestSchema (const)", - "ScheduleExportResponse": "src/api/export.zod.ts#ScheduleExportResponse (type)", - "ScheduleExportResponseParsed": "src/api/export.zod.ts#ScheduleExportResponseParsed (type)", - "ScheduleExportResponseSchema": "src/api/export.zod.ts#ScheduleExportResponseSchema (const)", - "ScheduledExport": "src/api/export.zod.ts#ScheduledExport (type)", - "ScheduledExportParsed": "src/api/export.zod.ts#ScheduledExportParsed (type)", - "ScheduledExportSchema": "src/api/export.zod.ts#ScheduledExportSchema (const)", "SearchAllHit": "src/api/protocol.zod.ts#SearchAllHit (type)", "SearchAllHitSchema": "src/api/protocol.zod.ts#SearchAllHitSchema (const)", "SearchAllPageHit": "src/api/protocol.zod.ts#SearchAllPageHit (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 17919145426..2d23fc6dce5 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -81,8 +81,6 @@ "api/CreateAiConversationRequest", "api/CreateDataRequest", "api/CreateDataResponse", - "api/CreateExportJobRequest", - "api/CreateExportJobResponse", "api/CreateFlowRequest", "api/CreateFlowResponse", "api/CreateImportJobRequest", @@ -149,9 +147,6 @@ "api/EventSubscription", "api/ExportFormat", "api/ExportImportTemplate", - "api/ExportJobProgress", - "api/ExportJobStatus", - "api/ExportJobSummary", "api/ExportRequest", "api/FieldError", "api/FieldErrorCode", @@ -173,8 +168,6 @@ "api/GetDiscoveryResponse", "api/GetEffectivePermissionsRequest", "api/GetEffectivePermissionsResponse", - "api/GetExportJobDownloadRequest", - "api/GetExportJobDownloadResponse", "api/GetFieldLabelsRequest", "api/GetFieldLabelsResponse", "api/GetFlowRequest", @@ -237,8 +230,6 @@ "api/ListAiPendingActionsRequest", "api/ListAiPendingActionsResponse", "api/ListDraftsResponse", - "api/ListExportJobsRequest", - "api/ListExportJobsResponse", "api/ListImportJobsRequest", "api/ListImportJobsResponse", "api/ListInstalledPackagesRequest", @@ -373,9 +364,6 @@ "api/RuntimeAuthoringIssue", "api/SaveMetaItemRequest", "api/SaveMetaItemResponse", - "api/ScheduleExportRequest", - "api/ScheduleExportResponse", - "api/ScheduledExport", "api/SearchAllHit", "api/SearchAllPageHit", "api/SearchAllResponse", 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 From f3ed706f010285f31d2fef52fad46db3432b93ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:36:39 +0000 Subject: [PATCH 11/11] revert: leave lint.yml's census-gate comment as it was It is historical prose no gate reads, and a workflow-touching diff is a named reason a PR cannot enter the merge queue. The comment's "82 rows" stays as a reading taken when that gate landed; noted in the PR's acceptance notes. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .github/workflows/lint.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 783fdeea847..c168ee6d449 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3586,9 +3586,8 @@ jobs: # exited 0; it was caught only because an unrelated gate happened to redden. # The file's own package suite is domain-level ("every registered dispatcher # domain has at least one ledger entry"), which a shorter list satisfies, and - # the cross-ledger guards compare against a UNION that held 26 of the then - # 82 rows' wire patterns from other ledgers when this gate landed (the - # dated reading is in the script's header). This gate holds the GENERATED + # the cross-ledger guards compare against a UNION that still holds 26 of the + # 82 rows' wire patterns from other ledgers. This gate holds the GENERATED # census sentence above the array to the array's real length, so a deletion # reds and a deliberate change shows its digits moving in the same diff. # Full-repo state, not diff-shaped, so it runs its real check unconditionally.