diff --git a/.changeset/19365-automation-runs-cursor-hasmore.md b/.changeset/19365-automation-runs-cursor-hasmore.md new file mode 100644 index 00000000000..232d8315a33 --- /dev/null +++ b/.changeset/19365-automation-runs-cursor-hasmore.md @@ -0,0 +1,117 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': minor +'@objectstack/service-automation': minor +'@objectstack/client': minor +--- + +feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19543) + +This door declared a pagination parameter it never spent and then reported, as a +literal, that there was nothing more to fetch. Both halves are closed here, per +the maintainer-approved ruling of 2026-09-21 (decision batch #204 item 2, +letter C of three). + +**BREAKING** — `cursor` no longer parses on `ListRunsRequestSchema`, its slot +is gone from `IAutomationService.listRuns`, and `@objectstack/client` no longer +declares or sends it on any of the three run-list surfaces +(`automation.runs.list`, `automation.listRuns`, +`client.environment(id).automation.listRuns`). It was declared on the wire, +*validated* at the boundary, forwarded into the service contract, appended by +the SDK, and read by no implementation. No emit site has ever written the +response half `nextCursor`, and the only ordering this door has is a required +but non-unique `startedAt` timestamp that nothing ever minted a resume point +from — so a caller looping "until the cursor runs out" re-read the first and +only window forever, with no error. + +``` +FROM ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> { name: 'f', limit: 20, cursor: 'n_007' } // forwarded, then dropped + +TO ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' }) + -> throws: '`cursor` was removed from GET /api/automation/:name/runs in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …' +``` + +`cursor` is a `retiredKey()` tombstone rather than a deletion: the request +schema is not `.strict()`, so a bare deletion would have made Zod silently strip +whatever a generated client kept sending — a clean parse and a parameter that +never takes effect, which is this defect re-created one layer down (ADR-0104). +Writing the key is now a `tsc` error and a parse error carrying the +prescription. + +**The SDK is retired in the same stroke, and that is what makes the sentence +above true.** Retiring the key in the schema alone would have left the one +generated client this repo ships typing it `string` and sending it into a route +that no longer reads it — the exact ADR-0104 shape the tombstone exists to +prevent, re-created one layer down, for the channel most callers actually reach +this door through. So the option is gone from all three surfaces and no +`?cursor=` is appended on any of them; an untyped caller cannot smuggle it past +the retired schema either, which is pinned. Same call as when #6361 retired the +notifications `cursor`: the client dropped the option and recorded the removal +in its docblock. + +``` +FROM client.automation.runs.list('f', { limit: 5, cursor: 'abc' }) + -> GET …/automation/f/runs?limit=5&cursor=abc // the key is dropped server-side + +TO client.automation.runs.list('f', { limit: 5 }) + -> GET …/automation/f/runs?limit=5 + // `{ cursor }` is now a TS2353 excess-property error; widen `limit` + // (1..100) and read `hasMore` instead. +``` + +**⛔ `limit` is NOT retired, and its `.default(20)` stays.** The sibling +`/packages` door retired *its* `limit` alongside `cursor` (#17667) because +nothing read it. That does not transfer, and the ruling says so explicitly: here +`limit` is read end to end — the HTTP boundary enforces the declared `1..100` +range read off the schema itself, the service takes it as an option, and the +engine spends it as the run store's history window. Retiring it would have been +a regression, not a narrowing. + +**`hasMore` is now computed, and this is a behaviour change callers can see.** +The door shipped `{ runs, hasMore: false }` with the `false` written as a +literal, beside a list the engine had already cut with `.slice(0, limit)`. A +caller asking for one row of a thousand was handed one row and told that was all +of them. A request whose window is shorter than the matching run set now +receives `hasMore: true` where it previously received `false`; a caller that +read `false` as "this is the whole history" was always wrong and is now told so. +`nextCursor` stays absent — nothing mints one. + +Read the new `false` with **one qualification**: unfiltered it is exact, but +under `?status=` it means "no further match inside the window that was scanned" +rather than "none exists", because the durable history source has no status slot +and the window is taken before the filter is applied. Pushing the filter down is +a `RunStore` contract change this card did not scope. The published +`RunListResult.hasMore` docblock and the response schema's own description both +carry that qualification, so a consumer meets it where they meet the field. + +**How truncation is established, because the obvious signal is wrong.** +`runs.length === limit` cannot tell a flow holding exactly `limit` runs from one +holding ten thousand; the two windows are byte-identical. So +`AutomationEngine` over-reads its history source by exactly one row and compares +the merged, filtered, ordered set against the caller's window. +`RunStore.listHistory`'s signature is deliberately unchanged — over-reading is +expressible in the `limit` it already takes. + +**New:** `IAutomationService.listRunsPage`, an optional member returning +`{ runs, hasMore }` (the shape `IExportService.listExportJobs` already uses, +minus the cursor nothing mints), plus the exported `RunListResult`. The engine +implements it and `listRuns` is its `runs` half, so there is one implementation +and no second copy to rot. A deployment whose automation service does not +implement it answers `501` naming the member, never a `200` carrying a guessed +`hasMore`. + +**One strictness regression, stated because it reverses a recorded decision.** +`?cursor=a&cursor=b` used to answer `400 VALIDATION_FAILED` and now answers +`200` with the key ignored, like any other unrecognised query name. #7300 +validated the key rather than deciding it, so that a future cursor +implementation would not be the one to discover the type was unenforced; this +ruling decides it instead — there will be no cursor implementation on this +door — so the refusal would be validating a key the contract no longer has. +This route declares no closed query-parameter set, so an unrecognised name has +never been refused here on its own account. + +Clause-②: yes + + diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 775fd0dc0eb..191d0761037 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1842,7 +1842,7 @@ curl -b cookies.txt -X POST \ | Endpoint | Purpose | |:---|:---| | `POST /api/v1/automation/:name/trigger` | Start a flow (canonical) | -| `GET /api/v1/automation/:name/runs` | List runs (`?limit`, `?cursor`, `?status` — narrow to one execution status; an undeclared value is refused `400 VALIDATION_FAILED`). Requires read on `sys_automation_run` — see [Observing runs](#observing-runs) | +| `GET /api/v1/automation/:name/runs` | List runs (`?limit` — 1–100, default 20, the window and the only way to ask for more; `?status` — narrow to one execution status; an undeclared value is refused `400 VALIDATION_FAILED`). `?cursor` was **removed in `@objectstack/spec` 17.5** (#19543): this door mints no continuation token, so a request still carrying it is ignored rather than refused — it used to answer `400 VALIDATION_FAILED` when repeated. The response `hasMore` is computed from the engine's truncation report rather than the constant `false` it used to be, so widen `?limit` when it is `true` — with `?status=`, a `false` means no further match inside the scanned window rather than none at all, because the window is taken before the filter is applied. `501 NOT_IMPLEMENTED` when the service does not declare `listRunsPage`. Requires read on `sys_automation_run` — see [Observing runs](#observing-runs) | | `GET /api/v1/automation/:name/runs/:runId` | One run's detail (404 `Execution not found`). Requires read on `sys_automation_run` — see [Observing runs](#observing-runs) | | `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` | | `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index c2f7671dbc7..0b4f9d48140 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -524,7 +524,7 @@ const result = AutomationApiErrorCode.parse(data); | **name** | `string` | ✅ | Flow machine name (snake_case) | | **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | optional | Filter by execution status | | **limit** | `integer` | optional (default: `20`) | Maximum number of runs to return | -| **cursor** | `string` | optional | Cursor for pagination | +| **cursor** | `never` | optional | [REMOVED] `cursor` was removed from GET /api/automation/:name/runs in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it was VALIDATED at the boundary and then read by nothing: the option reached the service and the engine never looked at it, no emit site has ever written the response half `nextCursor`, and the only ordering this door has is a required but non-unique `startedAt` timestamp that nothing ever minted a resume point from — so a caller looping "until the cursor runs out" re-read the first and only window forever, with no error. Delete the key. `limit` is the real window and STAYS: it is read end to end (boundary to service to store) and bounded to 1..100, so ask for a wider window instead of a next page. Read the response `hasMore` to learn whether the window was short — it is now COMPUTED from the engine rather than the constant `false` it used to be. | --- @@ -570,7 +570,7 @@ const result = AutomationApiErrorCode.parse(data); | **runs** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| …>; … }[]` | ✅ | Execution run logs | | **total** | `integer` | optional | Total matching runs | | **nextCursor** | `string` | optional | Cursor for the next page | -| **hasMore** | `boolean` | ✅ | Whether more runs are available | +| **hasMore** | `boolean` | ✅ | Whether more runs matched than this response carries — widen `limit` to see them. Under `status`, `false` means no further match within the scanned window rather than none at all: the window is taken before the filter is applied. | --- diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 396e72624d6..daf3312c6a4 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1425,19 +1425,62 @@ describe('ObjectStackClient.automation', () => { expect(result.runs).toHaveLength(1); }); - it('should list runs with pagination options', async () => { + it('should list runs with a window', async () => { const { client, fetchMock } = createMockClient({ success: true, data: { runs: [], hasMore: false }, }); - await client.automation.runs.list('my_flow', { limit: 5, cursor: 'abc' }); + // `limit` is the whole query surface of this door now. It used to be + // pinned here alongside `cursor=abc`; that half moved to the absence + // pin below when #19543 retired the key. + await client.automation.runs.list('my_flow', { limit: 5 }); expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3000/api/v1/automation/my_flow/runs?limit=5&cursor=abc', + 'http://localhost:3000/api/v1/automation/my_flow/runs?limit=5', expect.any(Object), ); }); + it('[#19543] never puts a `cursor` on the query string — on ANY of the three run-list surfaces', async () => { + // This test used to assert the OPPOSITE — it pinned the URL + // `…/runs?limit=5&cursor=abc`, i.e. that the SDK produced the key. That + // is what made the parameter harmful rather than inert: `cursor` was + // accepted at the boundary and read by nothing, so a caller paginating + // by the published contract re-read the first window forever with no + // error. #19543 retires it, and the assertion inverts on the same input. + // + // The type surface is the enforced channel — `list({ cursor })` is a + // TS2353 excess-property error, which a runtime assertion cannot reach. + // This pins the RUNTIME half, which tsc cannot: an untyped caller + // (plain JS, a `Record` spread, a hand-built options object) must not + // smuggle the parameter through. The same shape #6361 left behind one + // door over. + // + // All THREE surfaces are swept, because all three appended it and a + // caller reaching the door through any of them was equally misled. + const smuggled = { limit: 5, cursor: 'abc' }; + + const a = createMockClient({ success: true, data: { runs: [], hasMore: false } }); + await a.client.automation.runs.list('my_flow', smuggled as unknown as { limit?: number }); + + const b = createMockClient({ success: true, data: { runs: [], hasMore: false } }); + await b.client.automation.listRuns('my_flow', smuggled as unknown as { limit?: number }); + + const c = createMockClient({ success: true, data: { runs: [], hasMore: false } }); + await c.client.environment('proj-alpha').automation.listRuns( + 'my_flow', smuggled as unknown as { limit?: number }, + ); + + for (const [label, m] of [['runs.list', a], ['listRuns', b], ['environment().listRuns', c]] as const) { + const url = m.fetchMock.mock.calls[0][0] as string; + // The over-block guard: the window the caller DID ask for still + // arrives, so this pins a retirement and not a dead door. + expect(url, `${label} dropped the limit it was given`).toContain('limit=5'); + expect(url, `${label} still appends a retired cursor`).not.toContain('cursor'); + expect(url, `${label} leaked the cursor value`).not.toContain('abc'); + } + }); + it('should get a single run', async () => { const { client, fetchMock } = createMockClient({ success: true, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0e03a63bc41..fc23fd963d4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5534,13 +5534,33 @@ export class ObjectStackClient { */ runs: { /** - * List execution runs for a flow + * List execution runs for a flow. + * + * Returns the newest `limit` runs — a WINDOW, not a page. The + * `cursor` parameter was removed in `@objectstack/spec` 17.5.0 + * (#19543): it was appended to the query string here, validated at + * the boundary and read by nothing beyond it, so a caller + * paginating by it re-read the first window forever. + * + * Omit `limit` to take the server's window (20). The declared range + * is 1..100, and a value this method SENDS that falls outside it is + * REFUSED with `400 VALIDATION_FAILED`, never clamped — so raise it + * deliberately to see further back. + * + * ⚠️ `0` and `NaN` are the exception, and they are dropped rather + * than refused: the guard below is truthy, so a falsy `limit` never + * leaves the client and the server answers its DEFAULT window + * instead. `-5`, `1.5` and `101` are truthy, are sent, and are + * refused. The two `listRuns` surfaces guard on `!= null` and do + * send `0`. + * + * There is no continuation token — read `hasMore` to learn whether + * the window was short. */ - list: async (flowName: string, options?: { limit?: number; cursor?: string }): Promise<{ runs: ExecutionLog[]; hasMore: boolean }> => { + list: async (flowName: string, options?: { limit?: number }): Promise<{ runs: ExecutionLog[]; hasMore: boolean }> => { const route = this.getRoute('automation'); const params = new URLSearchParams(); if (options?.limit) params.set('limit', String(options.limit)); - if (options?.cursor) params.set('cursor', options.cursor); const qs = params.toString(); const res = await this.fetch(`${this.baseUrl}${route}/${flowName}/runs${qs ? `?${qs}` : ''}`); return this.unwrapResponse(res); @@ -5606,15 +5626,20 @@ export class ObjectStackClient { }); return this.unwrapResponse(res) as Promise; }, - /** Alias for `automation.runs.list`. */ + /** + * Alias for `automation.runs.list`. + * + * `cursor` was removed in `@objectstack/spec` 17.5.0 (#19543) — see that + * method for the reason. A window, not a page: widen `limit` + * (1..100, default 20) and read `hasMore`. + */ listRuns: async ( flowName: string, - opts?: { limit?: number; cursor?: string; status?: ExecutionStatus }, + opts?: { limit?: number; status?: ExecutionStatus }, ): Promise => { const route = this.getRoute('automation'); const params = new URLSearchParams(); if (opts?.limit != null) params.set('limit', String(opts.limit)); - if (opts?.cursor) params.set('cursor', opts.cursor); // [#7359] The route's declared `status` filter, now that the boundary // honours it instead of dropping it. Until this card the typed client // could not send it at all — which is why nothing had tripped over the @@ -8086,14 +8111,19 @@ export class ScopedEnvironmentClient { }); return this.parent._unwrap(res); }, - /** List recent runs for a flow, optionally narrowed to one status. */ + /** + * List recent runs for a flow, optionally narrowed to one status. + * + * `cursor` was removed in `@objectstack/spec` 17.5.0 (#19543) — see + * `automation.runs.list` for the reason. A window, not a page: widen + * `limit` (1..100, default 20) and read `hasMore`. + */ listRuns: async ( flowName: string, - opts?: { limit?: number; cursor?: string; status?: ExecutionStatus }, + opts?: { limit?: number; status?: ExecutionStatus }, ): Promise => { const params = new URLSearchParams(); if (opts?.limit != null) params.set('limit', String(opts.limit)); - if (opts?.cursor) params.set('cursor', opts.cursor); // [#7359] — see the sibling `listRuns` alias above. if (opts?.status) params.set('status', opts.status); const qs = params.toString(); 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 7b8ebbbce5c..f8e7fa5a56f 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 @@ -73,7 +73,7 @@ interface ExplainCall { interface Harness { dispatcher: HttpDispatcher; getRun: ReturnType; - listRuns: ReturnType; + listRunsPage: ReturnType; listFlows: ReturnType; getFlowRuntimeStates: ReturnType; getSuspendedScreen: ReturnType; @@ -94,7 +94,12 @@ function makeDispatcher( ): Harness { const explainCalls: ExplainCall[] = []; const getRun = vi.fn(async () => PAUSED_RUN as unknown); - const listRuns = vi.fn(async () => [PAUSED_RUN] as unknown[]); + // [#19543] The door reads the PAGE member — `listRuns` alone cannot + // report truncation, so a door that has to answer `hasMore` calls this + // one. The gate under test is unaffected either way: it refuses ahead of + // the service probe, deliberately, so that a 501-vs-403 is not what tells + // an ungranted caller whether automation is mounted here. + const listRunsPage = vi.fn(async () => ({ runs: [PAUSED_RUN], hasMore: false } as unknown)); const listFlows = vi.fn(async () => ['approval_flow']); const getFlowRuntimeStates = vi.fn(() => [{ name: 'approval_flow', enabled: true, bound: true }]); const getSuspendedScreen = vi.fn(async () => ({ nodeId: 'collect', fields: [] } as unknown)); @@ -112,7 +117,7 @@ function makeDispatcher( automation: { handlerReady: true, getRun, - listRuns, + listRunsPage, listFlows, getFlowRuntimeStates, getSuspendedScreen, @@ -137,7 +142,7 @@ function makeDispatcher( return { dispatcher: new HttpDispatcher(kernel as never), getRun, - listRuns, + listRunsPage, listFlows, getFlowRuntimeStates, getSuspendedScreen, @@ -193,7 +198,7 @@ describe('#7900 — /automation run-state reads require the sys_automation_run r expect(codeOf(response)).toBe('PERMISSION_DENIED'); expect((response as any).status).toBe(403); - expect(h.listRuns).not.toHaveBeenCalled(); + expect(h.listRunsPage).not.toHaveBeenCalled(); }); it('does not answer the caller\'s authorization topology in the refusal (#7450)', async () => { @@ -247,7 +252,7 @@ describe('#7900 — /automation run-state reads require the sys_automation_run r ); expect(payloadOf(response)).toEqual({ runs: [PAUSED_RUN], hasMore: false }); - expect(h.listRuns).toHaveBeenCalled(); + expect(h.listRunsPage).toHaveBeenCalled(); }); }); diff --git a/packages/runtime/src/domains/automation-runs-query-validation.test.ts b/packages/runtime/src/domains/automation-runs-query-validation.test.ts index 682fda1ad89..9838e00f68f 100644 --- a/packages/runtime/src/domains/automation-runs-query-validation.test.ts +++ b/packages/runtime/src/domains/automation-runs-query-validation.test.ts @@ -64,17 +64,37 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpDispatcher } from '../http-dispatcher.js'; import { validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js'; -/** An automation slot whose `listRuns` records exactly what it was asked for. */ -function makeDispatcher() { - const listRuns = vi.fn(async () => [{ id: 'run_1', flowName: 'welcome_flow', status: 'completed' }]); - const services: Record = { automation: { listRuns, handlerReady: true } }; +/** + * An automation slot whose `listRunsPage` records exactly what it was asked + * for. + * + * [#19543] The double serves `listRunsPage` — the page-shaped member the door + * now calls — rather than `listRuns`. The recorded OPTIONS object is what + * every preservation row below pins, and it is unchanged by that switch except + * for the retired `cursor` key: the door still forwards the caller's own + * `limit`, ⛔ never a widened one. The over-read that makes `hasMore` + * answerable lives in the ENGINE, behind this member, which is exactly why the + * window a caller asks for is still the window the service is asked for. + */ +function makeDispatcher(hasMore = false) { + // The parameters are DECLARED, not inferred: an argument-less `vi.fn` + // types `mock.calls` as the empty tuple, so reading the options argument + // off a recorded call is a type error (TS2493) — and the options argument + // is precisely what the preservation rows below exist to inspect. + const listRunsPage = vi.fn( + async (_flowName: string, _options?: Record) => ({ + runs: [{ id: 'run_1', flowName: 'welcome_flow', status: 'completed' }], + hasMore, + }), + ); + const services: Record = { automation: { listRunsPage, handlerReady: true } }; const resolve = (name: string) => services[name]; const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve }, }; - return { dispatcher: new HttpDispatcher(kernel), listRuns }; + return { dispatcher: new HttpDispatcher(kernel), listRunsPage }; } const CTX = () => ({ request: {}, executionContext: { userId: 'user_1' } } as any); @@ -87,7 +107,7 @@ const CTX = () => ({ request: {}, executionContext: { userId: 'user_1' } } as an * `errorResponseBase` — #3918). */ async function refusalFor(query: Record) { - const { dispatcher, listRuns } = makeDispatcher(); + const { dispatcher, listRunsPage } = makeDispatcher(); let thrown: unknown; let response: unknown; try { @@ -102,7 +122,7 @@ async function refusalFor(query: Record) { typeof (thrown as any)?.status === 'number' ? (thrown as any).status : details ? VALIDATION_FAILED_STATUS : 500; - return { details, status, listRuns, message: (thrown as Error).message }; + return { details, status, listRunsPage, message: (thrown as Error).message }; } describe('#7300 — GET /automation/:name/runs refuses a malformed `limit` instead of listing with NaN', () => { @@ -114,7 +134,7 @@ describe('#7300 — GET /automation/:name/runs refuses a malformed `limit` inste ['repeated parameter', ['1', '2']], ['structured', { $gt: 1 }], ])('refuses ?limit=%s with 400 VALIDATION_FAILED', async (_label, raw) => { - const { details, status, listRuns } = await refusalFor({ limit: raw }); + const { details, status, listRunsPage } = await refusalFor({ limit: raw }); // ADR-0112: the envelope, not merely the throw — `code` AND `status`. expect(details?.code).toBe('VALIDATION_FAILED'); @@ -126,7 +146,7 @@ describe('#7300 — GET /automation/:name/runs refuses a malformed `limit` inste ]); // The whole point: the service is never reached with a poisoned window, // so no caller is handed `[]` as if it were the flow's run history. - expect(listRuns).not.toHaveBeenCalled(); + expect(listRunsPage).not.toHaveBeenCalled(); }); it('names the offending value in the message, capped so the body cannot be stuffed', async () => { @@ -137,23 +157,125 @@ describe('#7300 — GET /automation/:name/runs refuses a malformed `limit` inste }); }); -describe("#7300 — the same probe on this route's other passed-through parameter", () => { +describe('#19543 — `cursor` is RETIRED, so this boundary stops reading it', () => { + // ⚠️ This block SUPERSEDES #7300's cursor refusal cases rather than + // extending them, and the supersession is a deliberate reversal, not a + // relaxation that slipped through. #7300 refused `?cursor=a&cursor=b` with + // 400 VALIDATION_FAILED because an ARRAY reached a slot the contract typed + // `cursor?: string`, and it chose to validate the key rather than decide + // it — on the reasoning that a future cursor implementation must not be + // the one to discover the type was unenforced. The maintainer ruling of + // decision batch #204 item 2 (letter C) decides it: there will be no + // cursor implementation on this door. The key is a `retiredKey()` + // tombstone on `ListRunsRequestSchema`, the slot is gone from + // `IAutomationService.listRuns`, and a refusal here would be validating a + // key the contract no longer has. + // + // Same input, opposite behaviour — the shape #7359 and #8054 already used + // on this file's other two parameters. it.each([ - ['repeated parameter', ['n_1', 'n_2']], + ['a plain value', 'n_007'], + ['the empty spelling #7300 passed through verbatim', ''], + ['repeated parameter — the exact input that used to answer 400', ['n_1', 'n_2']], ['structured', { $ne: 'n_1' }], ['numeric', 7], - ])('refuses ?cursor=%s rather than handing a non-string to a `cursor?: string` slot', async (_label, raw) => { - const { details, status, listRuns } = await refusalFor({ cursor: raw }); + ])('?cursor=%s is IGNORED — 200, and no `cursor` reaches the service', async (_label, raw) => { + const { dispatcher, listRunsPage } = makeDispatcher(); + const result = await dispatcher.handleAutomation( + 'welcome_flow/runs', 'GET', undefined, CTX(), { cursor: raw }, + ); - expect(details?.code).toBe('VALIDATION_FAILED'); - expect(status).toBe(400); - expect(details?.fields).toEqual([ - { field: 'cursor', code: 'invalid_type', message: expect.stringContaining('`cursor`') }, - ]); - expect(listRuns).not.toHaveBeenCalled(); + // Not a 400 any more. This route declares no closed query-parameter + // set, so an unrecognised name has never been refused here on its own + // account — `cursor` was refused because it was READ, and it no longer + // is. + expect(result.response?.status).toBe(200); + // The half that actually matters: nothing named `cursor` survives into + // the options object. A tolerant passthrough would have re-created the + // declared-and-ignored parameter this card exists to close. + expect(listRunsPage).toHaveBeenCalledTimes(1); + const options = listRunsPage.mock.calls[0]?.[1] as Record | undefined; + expect(options).not.toHaveProperty('cursor'); + }); + + it('the retired key is still refused where it IS parsed — the spec schema', async () => { + // The boundary ignores it; the tombstone is what makes the removal + // audible, and it lives on the schema. Pinned here as well as in + // `automation-api.zod.test.ts` so the runtime side records WHERE the + // loudness moved to when this handler stopped refusing. + const { ListRunsRequestSchema } = await import('@objectstack/spec/api'); + expect(() => ListRunsRequestSchema.parse({ name: 'welcome_flow', cursor: 'n_007' })) + .toThrow(/`cursor`.*removed/s); + }); +}); + +describe('#19543 — `hasMore` is RELAYED from the service, never a constant', () => { + // The defect this closes, in the source's own words: the door returned + // `deps.success({ runs, hasMore: false })` — a literal — beside a list the + // engine had already cut with `.slice(0, limit)`. A caller asking for one + // row of a thousand was handed one row and told that was all of them, with + // a 200 and nothing in the status, headers or body to distinguish it from + // a complete answer. + // + // ⛔ These cases assert the RELAY, not the truncation arithmetic. Whether + // `hasMore` is itself correct is the ENGINE's obligation and is pinned + // where the over-read happens, in service-automation's own suite — a door + // that recomputed it here would be a second implementation of the same + // invariant, and the one that rots. + + async function listRunsWith(hasMore: boolean) { + const { dispatcher, listRunsPage } = makeDispatcher(hasMore); + const result = await dispatcher.handleAutomation( + 'welcome_flow/runs', 'GET', undefined, CTX(), { limit: '1' }, + ); + return { result, listRunsPage }; + } + + it('relays `hasMore: true` — the case the old literal got WRONG', async () => { + const { result } = await listRunsWith(true); + + expect(result.response?.status).toBe(200); + // Against the unfixed door this is the failing assertion: it answered + // `false` here, always. + expect(result.response?.body?.data?.hasMore).toBe(true); + expect(result.response?.body?.data?.runs).toHaveLength(1); + }); + + it('relays `hasMore: false` — the over-block guard', async () => { + // The literal was `false`, so a fix that simply hard-coded `true` + // would pass the case above and be just as wrong. Both directions have + // to come from the service. + const { result } = await listRunsWith(false); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.hasMore).toBe(false); + }); + + it('answers 501 when the service implements no `listRunsPage` — ⛔ never a 200', async () => { + // "Absence must be loud." A service that cannot report truncation + // leaves this door with nothing honest to put in a REQUIRED response + // field, so it says so and names the member. Falling through to the + // domain's 404 would have been the silent form — the caller could not + // tell "no run listing is mounted here" from "no such flow" — and a + // 200 carrying a guessed `hasMore` would re-create the exact defect + // this card closed. + const services: Record = { automation: { handlerReady: true } }; + const resolve = (name: string) => services[name]; + const kernel: any = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + const result = await new HttpDispatcher(kernel) + .handleAutomation('welcome_flow/runs', 'GET', undefined, CTX(), undefined); + + expect(result.response?.status).toBe(501); + expect(result.response?.body?.error?.message).toContain('listRunsPage'); + expect(result.response?.body?.data?.hasMore).toBeUndefined(); }); }); + describe('#7359 — a `?status=` outside the declared set is refused, not silently widened', () => { it.each([ ['a typo', 'faild'], @@ -167,7 +289,7 @@ describe('#7359 — a `?status=` outside the declared set is refused, not silent // because "no runs are `faild`" and "no runs failed" read identically to // a caller who cannot see their own typo. Both are a monitoring surface // answering "you have no failures" with confidence. - const { details, status, listRuns } = await refusalFor({ status: raw }); + const { details, status, listRunsPage } = await refusalFor({ status: raw }); expect(details?.code).toBe('VALIDATION_FAILED'); expect(status).toBe(400); @@ -177,7 +299,7 @@ describe('#7359 — a `?status=` outside the declared set is refused, not silent expect(details?.fields).toEqual([ { field: 'status', code: 'invalid_option', message: expect.stringContaining('`status`') }, ]); - expect(listRuns).not.toHaveBeenCalled(); + expect(listRunsPage).not.toHaveBeenCalled(); }); it.each([ @@ -189,14 +311,14 @@ describe('#7359 — a `?status=` outside the declared set is refused, not silent // repeated `?status=failed&status=completed` arrives as an ARRAY, and a // filter is not a set on this wire. `String([...])` would have made it // the single value `'failed,completed'`, matching nothing. - const { details, status, listRuns } = await refusalFor({ status: raw }); + const { details, status, listRunsPage } = await refusalFor({ status: raw }); expect(details?.code).toBe('VALIDATION_FAILED'); expect(status).toBe(400); expect(details?.fields).toEqual([ { field: 'status', code: 'invalid_type', message: expect.stringContaining('`status`') }, ]); - expect(listRuns).not.toHaveBeenCalled(); + expect(listRunsPage).not.toHaveBeenCalled(); }); it('names the declared members in the message, and caps the echoed value', async () => { @@ -223,7 +345,7 @@ describe('#8054 — a `?limit=` outside the declared 1..100 range is refused, no ['101 (one past the declared cap)', '101', 'max_value'], ['1000 (far past the declared cap — the old preserved case, inverted)', '1000', 'max_value'], ])('refuses ?limit=%s with 400 VALIDATION_FAILED (%s)', async (_label, raw, expectedCode) => { - const { details, status, listRuns } = await refusalFor({ limit: raw }); + const { details, status, listRunsPage } = await refusalFor({ limit: raw }); // ADR-0112: the envelope, not merely the throw — `code` AND `status`. expect(details?.code).toBe('VALIDATION_FAILED'); @@ -236,7 +358,7 @@ describe('#8054 — a `?limit=` outside the declared 1..100 range is refused, no // The whole point: the service is never reached with a limit outside // its own declared contract, so no caller reads a wrong-but-confident // "no runs" and no caller gets an uncapped result set. - expect(listRuns).not.toHaveBeenCalled(); + expect(listRunsPage).not.toHaveBeenCalled(); }); // The boundary values themselves — `?limit=1` and `?limit=100` — are @@ -246,19 +368,19 @@ describe('#8054 — a `?limit=` outside the declared 1..100 range is refused, no describe('#7300 — every value that had a defensible answer keeps it', () => { async function listWith(query: Record | undefined) { - const { dispatcher, listRuns } = makeDispatcher(); + const { dispatcher, listRunsPage } = makeDispatcher(); const result = await dispatcher.handleAutomation('welcome_flow/runs', 'GET', undefined, CTX(), query); - return { result, listRuns }; + return { result, listRunsPage }; } it.each([ // [label, query, the exact options object `listRuns` must receive] - ['?limit=20', { limit: '20' }, { limit: 20, cursor: undefined, status: undefined }], + ['?limit=20', { limit: '20' }, { limit: 20, status: undefined }], // An ordinary in-range value is the over-block guard for #8054: bounds // threading must not start refusing numbers that were always fine. - ['?limit=25 (ordinary, mid-range)', { limit: '25' }, { limit: 25, cursor: undefined, status: undefined }], - ['?limit=1 (the low boundary)', { limit: '1' }, { limit: 1, cursor: undefined, status: undefined }], - ['?limit=100 (the declared high boundary)', { limit: '100' }, { limit: 100, cursor: undefined, status: undefined }], + ['?limit=25 (ordinary, mid-range)', { limit: '25' }, { limit: 25, status: undefined }], + ['?limit=1 (the low boundary)', { limit: '1' }, { limit: 1, status: undefined }], + ['?limit=100 (the declared high boundary)', { limit: '100' }, { limit: 100, status: undefined }], // Out-of-RANGE numbers used to be preserved here (`?limit=1000`, // `?limit=-5`, `?limit=0`) on the theory that range was the engine's // declared business, not this boundary's. #8054 found the one place @@ -273,30 +395,32 @@ describe('#7300 — every value that had a defensible answer keeps it', () => { // `''`, and an in-process (non-string) `0` never reach it. `'0'` as a // QUERY-STRING value is different — the string is truthy, so it always // reached `Number()` — and is exercised in the `#8054` block instead. - ['?limit= (empty)', { limit: '' }, { limit: undefined, cursor: undefined, status: undefined }], - ['limit: 0 (in-process number)', { limit: 0 }, { limit: undefined, cursor: undefined, status: undefined }], - ['limit: null', { limit: null }, { limit: undefined, cursor: undefined, status: undefined }], - ['no parameters at all', {}, { limit: undefined, cursor: undefined, status: undefined }], - // A cursor is opaque: every string passes through VERBATIM, including - // the empty one, exactly as the raw passthrough did. - ['?cursor=n_007', { cursor: 'n_007' }, { limit: undefined, cursor: 'n_007', status: undefined }], - ['?cursor= (empty)', { cursor: '' }, { limit: undefined, cursor: '', status: undefined }], - ['both together', { limit: '5', cursor: 'n_007' }, { limit: 5, cursor: 'n_007', status: undefined }], + ['?limit= (empty)', { limit: '' }, { limit: undefined, status: undefined }], + ['limit: 0 (in-process number)', { limit: 0 }, { limit: undefined, status: undefined }], + ['limit: null', { limit: null }, { limit: undefined, status: undefined }], + ['no parameters at all', {}, { limit: undefined, status: undefined }], + // The three `?cursor=` preservation rows that stood here — a verbatim + // string, the empty spelling, and `limit` + `cursor` together — are + // superseded by the `#19543` block above rather than deleted outright: + // the key is retired, so "reaches the service unchanged" is no longer + // the behaviour to preserve. What replaced them asserts the opposite + // on the same inputs, which is the same supersession shape #7359 and + // #8054 used on this route's other parameters. ])('%s answers 200 and reaches the service unchanged', async (_label, query, expected) => { - const { result, listRuns } = await listWith(query); + const { result, listRunsPage } = await listWith(query); expect(result.response?.status).toBe(200); - expect(listRuns).toHaveBeenCalledWith('welcome_flow', expected); + expect(listRunsPage).toHaveBeenCalledWith('welcome_flow', expected); }); it('passes NO options at all when the transport delivers no query object', async () => { // Preserved verbatim from `query ? { … } : undefined`: an absent query // means the service applies its own default window (20), which is a // different statement from "a window of `undefined`" and stays so. - const { result, listRuns } = await listWith(undefined); + const { result, listRunsPage } = await listWith(undefined); expect(result.response?.status).toBe(200); - expect(listRuns).toHaveBeenCalledWith('welcome_flow', undefined); + expect(listRunsPage).toHaveBeenCalledWith('welcome_flow', undefined); }); // ── #7359 ──────────────────────────────────────────────────────────────── @@ -312,10 +436,10 @@ describe('#7300 — every value that had a defensible answer keeps it', () => { // HTTP layer. The caller got 200 + every run of the flow — a caller // paging for failures read the first `limit` runs of ANY status and // concluded those were the failures. - const { result, listRuns } = await listWith({ limit: '2', status: 'failed' }); + const { result, listRunsPage } = await listWith({ limit: '2', status: 'failed' }); expect(result.response?.status).toBe(200); - expect(listRuns).toHaveBeenCalledWith('welcome_flow', { limit: 2, cursor: undefined, status: 'failed' }); + expect(listRunsPage).toHaveBeenCalledWith('welcome_flow', { limit: 2, status: 'failed' }); }); it.each(ExecutionStatus.options)('forwards every declared ExecutionStatus member — ?status=%s', async (member) => { @@ -334,10 +458,10 @@ describe('#7300 — every value that had a defensible answer keeps it', () => { // (#7359); `automation-api.zod.test.ts` turned the same copy into the // same read. ⛔ Iterating `.options` does not reorder it — the enum's // own note reserves those positions for readers that index them. - const { result, listRuns } = await listWith({ status: member }); + const { result, listRunsPage } = await listWith({ status: member }); expect(result.response?.status).toBe(200); - expect(listRuns).toHaveBeenCalledWith('welcome_flow', { limit: undefined, cursor: undefined, status: member }); + expect(listRunsPage).toHaveBeenCalledWith('welcome_flow', { limit: undefined, status: member }); }); it.each([ @@ -350,10 +474,10 @@ describe('#7300 — every value that had a defensible answer keeps it', () => { // not become a new 400: unlike `?read=`, which used to serve the wrong // HALF of the inbox, `?status=` already served exactly what "no filter" // means, so it had a defensible answer to preserve. - const { result, listRuns } = await listWith(query); + const { result, listRunsPage } = await listWith(query); expect(result.response?.status).toBe(200); - expect(listRuns).toHaveBeenCalledWith('welcome_flow', expect.objectContaining({ status: undefined })); + expect(listRunsPage).toHaveBeenCalledWith('welcome_flow', expect.objectContaining({ status: undefined })); }); it('still refuses an anonymous caller with 401 before it ever looks at the query', async () => { @@ -361,12 +485,12 @@ describe('#7300 — every value that had a defensible answer keeps it', () => { // must not become a 400 that confirms the route is wired and serveable // (#5519's anonymous baseline stands ahead of every parse on this // domain). - const { dispatcher, listRuns } = makeDispatcher(); + const { dispatcher, listRunsPage } = makeDispatcher(); const result = await dispatcher.handleAutomation( 'welcome_flow/runs', 'GET', undefined, { request: {} } as any, { limit: 'abc' }, ); expect(result.response?.status).toBe(401); - expect(listRuns).not.toHaveBeenCalled(); + expect(listRunsPage).not.toHaveBeenCalled(); }); }); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index b75d05346b4..6a9c7a98024 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -912,6 +912,9 @@ const RESTORE_REFUSAL_UNKNOWN_STATUS = 500; const RUN_CANCEL_UNSUPPORTED_MESSAGE = 'Cancelling a run is not supported by the automation service this deployment mounts — it does not implement ' + '`cancelRun`, an optional member of `IAutomationService`. No run was cancelled.'; +const RUNS_LIST_UNSUPPORTED_MESSAGE = + 'Listing the runs of a flow is not supported by the automation service this deployment mounts — it does not ' + + 'implement `listRunsPage`, an optional member of `IAutomationService`. No runs were listed.'; const RUN_RESTORE_UNSUPPORTED_MESSAGE = 'Restoring a consumed suspension is not supported by the automation service this deployment mounts — it does ' + 'not implement `restoreConsumedSuspension`, an optional member of `IAutomationService`. No suspension was ' @@ -1533,8 +1536,14 @@ async function consumedSuspensionSurvives( * are NOT re-pointed — the response says so (§9). * ⚑ authoring write — `manage_metadata`: it * registers flow metadata, like `POST /` - * GET /:name/runs → listRuns (query: limit, cursor — validated, #7300; - * status — validated AND honoured, #7359) + * GET /:name/runs → listRunsPage (query: limit — validated AND + * honoured end to end, #7300 / #8054; status — + * validated AND honoured, #7359; cursor — + * RETIRED, #19543, so a value carrying it is + * ignored rather than validated). `hasMore` is + * computed from the engine's own truncation + * report, never a constant. A service without + * `listRunsPage` → 501, ⛔ never a 200 * ⚑ run-state read — `sys_automation_run` grant (#7900) * GET /:name/runs/:runId → getRun * ⚑ run-state read — `sys_automation_run` grant (#7900) @@ -2537,9 +2546,9 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str } } - // GET /:name/runs → listRuns + // GET /:name/runs → listRunsPage if (parts[1] === 'runs' && !parts[2] && m === 'GET') { - if (typeof automationService.listRuns === 'function') { + if (typeof automationService.listRunsPage === 'function') { // [#7300] Both options are CHECKED at the point they are read, // in the shared query-parameter refusal this route now consumes // with `/notifications` (#6928 / PR #7299 — the same defect, one @@ -2562,6 +2571,36 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // first implementation that starts honouring cursors must not // be the one that discovers the type was never enforced. // + // [#19543] ⚠️ THAT SECOND BULLET IS NOW HISTORY, AND ITS + // DECISION IS REVERSED ON PURPOSE. #7300 chose to validate a + // key rather than decide it, on the reasoning that a future + // cursor implementation must not be the one to discover the + // type was unenforced. The maintainer ruling of decision batch + // #204 item 2 (letter C) decides it instead: there will be no + // cursor implementation on this door, so `cursor` is a + // `retiredKey()` tombstone on `ListRunsRequestSchema` and this + // boundary stops reading it. + // + // The wire consequence, stated because it is a REGRESSION in + // strictness and not a no-op: `?cursor=a&cursor=b` used to + // answer `400 VALIDATION_FAILED` and now answers `200`, the + // key ignored like any other unrecognised query name. Nothing + // is handed a wrong type by that — the option no longer exists + // to be filled — and this route has never declared a closed + // query set (Route & surface ownership rule 5), so an + // unrecognised name has never been refused here on its own + // account. ⛔ Do not re-add a `cursor` read to restore the + // 400: the refusal would be validating a key the contract no + // longer has. + // + // `limit` is UNTOUCHED by that retirement and stays fully + // read — it is this door's real window, bounded below by the + // schema's own `.min()`/`.max()` a few lines down, forwarded + // to the service, and spent by the engine as the store's + // history window. The sibling `/packages` door retired ITS + // `limit` (#17667) because nothing read it; the two doors + // looked identical and measured differently. + // // [#8054] `limit`'s RANGE — `ListRunsRequestSchema` has always // declared `.min(1).max(100)`, and until now this gate only // checked that the value was a whole number at all, never that @@ -2617,13 +2656,22 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str min: limitBounds.minValue ?? undefined, max: limitBounds.maxValue ?? undefined, }), - cursor: parseStringParam('cursor', query.cursor), status: parseEnumParam('status', query.status, ExecutionStatus.options), } : undefined; - const runs = await automationService.listRuns(name, options); - return { handled: true, response: deps.success({ runs, hasMore: false }) }; + const { runs, hasMore } = await automationService.listRunsPage(name, options); + return { handled: true, response: deps.success({ runs, hasMore }) }; } + // [#19543] A service that does not implement `listRunsPage` is told + // so, and ⛔ never answered 200 with an invented `hasMore`. Falling + // through to the domain's 404 would have been the silent form: the + // caller cannot tell "this deployment mounts no run listing" from + // "this flow does not exist", and the door's whole subject is a + // field that used to be confidently wrong. The 403 run-read gate + // above runs first and is unaffected — its own note says a + // 501-vs-403 must not be what tells an ungranted caller whether + // automation is mounted here. + return { handled: true, response: deps.error(RUNS_LIST_UNSUPPORTED_MESSAGE, 501) }; } // GET /:name → getFlow (no sub-path) diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 5406bcc922b..6abd400a37c 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -297,6 +297,13 @@ describe('HttpDispatcher', () => { execute: vi.fn().mockResolvedValue({ success: true, output: {} }), toggleFlow: vi.fn().mockResolvedValue(undefined), listRuns: vi.fn().mockResolvedValue([{ id: 'run_1', status: 'completed' }]), + // [#19543] The run-list door calls the PAGE member; `listRuns` + // stays declared here because the CONTRACT still declares it, + // and this mock's subject is contract completeness (#4127). + listRunsPage: vi.fn().mockResolvedValue({ + runs: [{ id: 'run_1', status: 'completed' }], + hasMore: false, + }), getRun: vi.fn().mockResolvedValue({ id: 'run_1', status: 'completed' }), resume: vi.fn().mockResolvedValue({ success: true, output: {}, durationMs: 7 }), // ASYNC per IAutomationService (#4515) — `Promise`. diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 90ccd6c29ed..bd3b5340d01 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -10,7 +10,7 @@ import type { FlowFunctionEffect, FlowRunSummary, } from '@objectstack/spec/automation'; -import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, ScreenSpec, ScreenFieldSpec } from '@objectstack/spec/contracts'; +import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, RunListResult, ScreenSpec, ScreenFieldSpec } from '@objectstack/spec/contracts'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; import { validateScreenInputs, @@ -4566,8 +4566,69 @@ export class AutomationEngine implements IAutomationService { async listRuns( flowName: string, - options?: { limit?: number; cursor?: string; status?: ExecutionStatus }, + options?: { limit?: number; status?: ExecutionStatus }, ): Promise { + // [#19543] ONE implementation, two projections — `listRunsPage` is the + // whole method and this is its `runs` half. ⛔ Never re-derive the + // listing here: a second copy of the merge/filter/sort would be the + // fork the route-ownership rule refuses, and it is the half that would + // rot, because the page method is the one the REST door calls. + return (await this.listRunsPage(flowName, options)).runs; + } + + /** + * [#19543] The run listing AND whether it was truncated — the member the + * REST door builds `hasMore` from. + * + * ## What "truncated" means at this seam, and why `runs.length === limit` is not it + * + * Three sources merge below and only ONE of them was ever capped: the + * durable HISTORY arm, because `RunStore.listHistory(flowName, limit)` + * takes the window as an argument. The durable PAUSED arm (`store.list()`) + * and the in-memory ring are read in full and contribute everything they + * hold for the flow. So before this change the merged set could be short + * for two indistinguishable reasons — the flow really has that many runs, + * or the store had more and was asked for exactly `limit`. + * + * `runs.length === limit` cannot separate them, which is why it is ⛔ not + * the signal: a flow with EXACTLY `limit` runs and a flow with ten + * thousand produce byte-identical windows, and reporting `hasMore: true` + * for the first is as wrong as `false` for the second. + * + * The signal is an OVER-READ of exactly one row. The history arm is asked + * for `limit + 1`; the merged, filtered, sorted set is then compared to + * `limit`. If it overflows, a run matched that this window does not carry + * and `hasMore` is true; if it does not, the window IS the answer. The + * extra row is dropped by the same `.slice(0, limit)` that was always + * here, so the wire shape does not change — only the fact reported beside + * it. ⛔ `RunStore.listHistory`'s signature is deliberately NOT redesigned: + * over-reading is expressible in the `limit` it already takes, so the + * truncation signal costs the store contract nothing. + * + * ## What `hasMore` does NOT mean + * + * ⛔ Not "the retention cap evicted older runs". A run the deployment's + * per-flow retention has discarded does not exist any more; it is not + * "more" and no `limit` will bring it back. This answers only about rows + * the sources still hold. + * + * ⛔ Not "there is a next page". This door mints no cursor (the request + * half is a retired key). The caller's remedy is a WIDER `limit`, up to + * the 100 the wire declares. + * + * ⚠️ One honest residual, pre-existing and unchanged: under `?status=`, + * the history arm's window is still the newest `limit + 1` rows of ANY + * status, because `listHistory` has no status slot and the filter is + * applied to what comes back. So a status-filtered `hasMore: false` means + * "no further match within the scanned window", not "no further match + * exists". Pushing the filter down is a store-contract change and is ⛔ not + * this card's; the same paragraph below the merge already records it for + * the listing itself. + */ + async listRunsPage( + flowName: string, + options?: { limit?: number; status?: ExecutionStatus }, + ): Promise { const limit = options?.limit ?? 20; const inMem = this.executionLogs.filter(l => l.flowName === flowName); @@ -4640,7 +4701,12 @@ export class AutomationEngine implements IAutomationService { let durable: ExecutionLogEntry[] = []; if (this.store?.listHistory) { try { - const rows = await this.store.listHistory(flowName, limit); + // [#19543] `limit + 1`, not `limit` — the over-read that makes + // `hasMore` answerable at all. Asking for exactly `limit` makes a + // saturated window and a complete one identical; one extra row + // tells them apart, and `.slice(0, limit)` below drops it again + // so nothing on the wire widens. + const rows = await this.store.listHistory(flowName, limit + 1); durable = rows.map(r => this.runRecordToLogEntry(r)); } catch (err) { // #6499 — the datasource driver's text to the structured slot; @@ -4727,9 +4793,16 @@ export class AutomationEngine implements IAutomationService { const merged = status === undefined ? [...byId.values()] : [...byId.values()].filter(e => e.status === status); - return merged - .sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? '')) - .slice(0, limit); + const ordered = merged + .sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? '')); + // [#19543] The comparison is against the ORDERED, FILTERED set, not + // against what any single source returned: a row can reach `ordered` + // from the ring or the paused arm without the history arm knowing, and + // a `?status=` filter can drop the over-read row specifically. Reading + // the overflow here — after every arm has contributed and after the + // filter has run — is what makes the answer true of the response + // actually being sent. + return { runs: ordered.slice(0, limit), hasMore: ordered.length > limit }; } /** Rehydrate a durable {@link RunRecord} into an {@link ExecutionLogEntry} diff --git a/packages/services/service-automation/src/run-list-truncation.test.ts b/packages/services/service-automation/src/run-list-truncation.test.ts new file mode 100644 index 00000000000..6935b68636c --- /dev/null +++ b/packages/services/service-automation/src/run-list-truncation.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #19543 — `AutomationEngine.listRunsPage` and the truncation boundary. + * + * `GET /api/automation/:name/runs` used to answer `{ runs, hasMore: false }` + * with the `false` written as a literal, beside a list the engine had already + * cut with `.slice(0, limit)`. A caller asking for one row of a thousand was + * handed one row and told that was all of them, with a `200` and nothing in + * the status, headers or body to distinguish it from a complete answer. The + * maintainer ruling of decision batch #204 item 2 (letter C) says the engine + * reports truncation to the door and `hasMore` is computed. + * + * ## The boundary these cases exist to pin, and why the obvious signal is wrong + * + * The tempting implementation is `hasMore = runs.length === limit`. It is + * WRONG at exactly one input, and that input is neither rare nor detectable + * from the response: a flow holding EXACTLY `limit` runs produces a window + * byte-identical to one held by a flow with ten thousand. Reporting `true` for + * the first is a lie — there is nothing more to fetch, and a caller that + * widens its window learns that only by doing the work. + * + * So the signal is an OVER-READ of exactly one row: the history arm is asked + * for `limit + 1` and the merged, filtered, ordered set is compared against + * `limit`. The three-case table below is the whole contract, and the middle + * row is the one that separates a correct implementation from the tempting + * one: + * + * | runs the store holds | hasMore | + * |----------------------|---------| + * | fewer than `limit` | false | + * | EXACTLY `limit` | false | ⭐ the case `length === limit` gets wrong + * | more than `limit` | true | + * + * ## What these cases deliberately do NOT execute + * + * No flow is run here. Seeding the durable store directly through + * `recordTerminal` is what makes the arithmetic readable: the in-memory ring + * and the paused arm both stay empty, so the merged set IS the history arm and + * a failing count cannot be blamed on a third source. The arms' merge is + * `run-history.test.ts`'s and `paused-run-visibility.test.ts`'s subject, not + * this file's. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AutomationEngine } from './engine.js'; +import type { RunRecord } from './engine.js'; +import { InMemorySuspendedRunStore, DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW } from './suspended-run-store.js'; + +const silent = { info() {}, warn() {}, error() {}, debug() {} } as never; + +const FLOW = 'runs_window'; + +/** One terminal history row, ordered by `startedAt` the way the store sorts. */ +function record(n: number, flowName = FLOW): RunRecord { + return { + // The run id carries the flow name because the store keys history by + // `runId` alone: two flows seeded with the same ids would overwrite + // each other's rows rather than coexist. + runId: flowName === FLOW ? `run_${String(n).padStart(4, '0')}` : `${flowName}_${n}`, + flowName, + status: 'completed', + // Descending `startedAt` order is what `listHistory` sorts on, so a + // bigger `n` is a NEWER run and lands earlier in the window. + startedAt: new Date(Date.UTC(2026, 0, 1) + n * 60_000).toISOString(), + finishedAt: new Date(Date.UTC(2026, 0, 1) + n * 60_000 + 1_000).toISOString(), + } as RunRecord; +} + +/** A store holding exactly `count` terminal runs for {@link FLOW}. */ +async function storeWith(count: number) { + // The per-flow retention cap is raised above every count used here so that + // eviction can never be what makes a case pass: these cases are about the + // WINDOW, and a run the cap evicted is not "more" — it does not exist any + // more and no `limit` brings it back. + const store = new InMemorySuspendedRunStore({ maxTerminalRunsPerFlow: 10_000 }); + for (let n = 1; n <= count; n += 1) await store.recordTerminal(record(n)); + return store; +} + +async function pageOf(count: number, limit: number) { + const engine = new AutomationEngine(silent, await storeWith(count)); + return engine.listRunsPage(FLOW, { limit }); +} + +describe('#19543 — hasMore at the truncation boundary', () => { + it.each([ + ['far fewer than the window', 3, 10, false, 3], + ['one short of the window', 9, 10, false, 9], + // ⭐ The case the tempting `runs.length === limit` signal gets wrong. + ['EXACTLY the window', 10, 10, false, 10], + ['one more than the window', 11, 10, true, 10], + ['far more than the window', 250, 10, true, 10], + // `limit: 1` is the shape the filed defect was reported against — ask + // for one row, be handed one row and told that is all of them. + ['a single-row window over many runs', 250, 1, true, 1], + ['a single-row window over a single run', 1, 1, false, 1], + ])('%s: %i runs, limit %i -> hasMore %s', async (_label, count, limit, hasMore, rows) => { + const page = await pageOf(count, limit); + + expect(page.hasMore, `${count} runs in a window of ${limit}`).toBe(hasMore); + // The window itself is never widened by the over-read — the extra row + // is dropped by the same `.slice(0, limit)` that was always here, so + // nothing on the wire grows. + expect(page.runs).toHaveLength(rows); + }); + + it('the extra row is a PROBE, not content — the window returns the NEWEST `limit` runs', async () => { + // If the over-read row ever leaked into the response the window would + // carry `limit + 1` rows, or the wrong ones. Both are checked: the + // newest run is `run_0100` and a window of 3 is exactly the top three. + const page = await pageOf(100, 3); + + expect(page.hasMore).toBe(true); + expect(page.runs.map((r) => r.id)).toEqual(['run_0100', 'run_0099', 'run_0098']); + }); + + it('asks the STORE for `limit + 1` — the over-read is where the fact comes from', async () => { + // The mechanism pin. `RunStore.listHistory`'s signature is deliberately + // unchanged (#19543): over-reading is expressible in the `limit` it + // already takes, so the truncation signal costs the store contract + // nothing. A regression to `listHistory(flow, limit)` would make the + // EXACTLY-the-window case above indistinguishable from the one above + // it, which is the defect this card closed. + const store = await storeWith(50); + const spy = vi.spyOn(store, 'listHistory'); + const engine = new AutomationEngine(silent, store); + + await engine.listRunsPage(FLOW, { limit: 20 }); + + expect(spy).toHaveBeenCalledWith(FLOW, 21); + }); + + it('applies the schema default window (20) when the caller names none', async () => { + // `ListRunsRequestSchema.limit` declares `.default(20)` and the engine + // carries the same number for a direct caller that passes no options. + // ⛔ `limit` is NOT retired on this door — the sibling `/packages` + // retirement (#17667) took its `limit` because nothing read it; here + // it is read end to end, and this case is the over-block guard. + expect((await pageOf(25, 20)).hasMore).toBe(true); + + const engine = new AutomationEngine(silent, await storeWith(25)); + const defaulted = await engine.listRunsPage(FLOW); + expect(defaulted.runs).toHaveLength(20); + expect(defaulted.hasMore).toBe(true); + + const exact = new AutomationEngine(silent, await storeWith(20)); + expect((await exact.listRunsPage(FLOW)).hasMore).toBe(false); + }); + + it('counts only the flow it was asked about', async () => { + const store = new InMemorySuspendedRunStore({ maxTerminalRunsPerFlow: 10_000 }); + for (let n = 1; n <= 3; n += 1) await store.recordTerminal(record(n)); + for (let n = 1; n <= 99; n += 1) await store.recordTerminal(record(n, 'other_flow')); + const engine = new AutomationEngine(silent, store); + + const page = await engine.listRunsPage(FLOW, { limit: 5 }); + expect(page.runs).toHaveLength(3); + expect(page.hasMore).toBe(false); + }); + + it('⛔ hasMore is NOT a report on runs retention already discarded', async () => { + // A run the deployment's per-flow cap evicted does not exist any more. + // It is not "more", and no `limit` will bring it back — so a flow whose + // history has been pruned to the cap answers `false` once the window + // covers what survives. Reporting `true` there would send a caller + // looking for rows that are gone. + const store = new InMemorySuspendedRunStore({ maxTerminalRunsPerFlow: 5 }); + for (let n = 1; n <= 40; n += 1) await store.recordTerminal(record(n)); + const engine = new AutomationEngine(silent, store); + + expect(await store.listHistory(FLOW, 100)).toHaveLength(5); + const page = await engine.listRunsPage(FLOW, { limit: 10 }); + expect(page.runs).toHaveLength(5); + expect(page.hasMore).toBe(false); + // And the cap is not a magic number here: the default is what a real + // deployment gets, and it is well above the wire's maximum window. + expect(DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW).toBeGreaterThanOrEqual(100); + }); +}); + +describe('#19543 — `listRuns` is the `runs` half of the same call', () => { + it('returns the identical window, and reports no truncation of its own', async () => { + // ONE implementation, two projections. A second merge/filter/sort here + // would be the fork the route-ownership rule refuses, and it is the + // half that would rot — the REST door calls the page method. + const store = await storeWith(30); + const engine = new AutomationEngine(silent, store); + + const page = await engine.listRunsPage(FLOW, { limit: 7 }); + const array = await engine.listRuns(FLOW, { limit: 7 }); + + expect(array.map((r) => r.id)).toEqual(page.runs.map((r) => r.id)); + expect(array).toHaveLength(7); + expect(page.hasMore).toBe(true); + }); + + it('still narrows by `status`, and the window still binds', async () => { + const store = new InMemorySuspendedRunStore({ maxTerminalRunsPerFlow: 10_000 }); + for (let n = 1; n <= 8; n += 1) { + await store.recordTerminal({ ...record(n), status: n % 2 === 0 ? 'failed' : 'completed' }); + } + const engine = new AutomationEngine(silent, store); + + const failed = await engine.listRunsPage(FLOW, { status: 'failed', limit: 10 }); + expect(failed.runs.map((r) => r.status)).toEqual(['failed', 'failed', 'failed', 'failed']); + // ⚠️ Honest residual, pre-existing and unchanged by this card: under a + // status filter the history arm's window is still the newest + // `limit + 1` rows of ANY status, because `listHistory` has no status + // slot and the filter is applied to what comes back. So a + // status-filtered `hasMore: false` means "no further match within the + // scanned window", not "no further match exists". Here the window + // covers the whole history, so the answer is exact. + expect(failed.hasMore).toBe(false); + }); +}); diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index a9650143311..51ee881a05a 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -253,6 +253,7 @@ "RlsMembershipContext (interface)", "RollbackInput (interface)", "RouteHandler (type)", + "RunListResult (interface)", "SEED_SETTLEMENT_SERVICE (const)", "SHARE_LINK_SERVICE (const)", "SaveReportInput (interface)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 038aa7f549f..3770b6729d1 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1057,7 +1057,7 @@ "api/ListRecordResponse:meta", "api/ListRecordResponse:pagination", "api/ListRecordResponse:success", - "api/ListRunsRequest:cursor", + "api/ListRunsRequest:cursor [RETIRED]", "api/ListRunsRequest:limit", "api/ListRunsRequest:name", "api/ListRunsRequest:status", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index aa45d56ee1b..5f2927f21a1 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -253,6 +253,7 @@ "RlsMembershipContext": "src/contracts/rls-membership-resolver.ts#RlsMembershipContext (interface)", "RollbackInput": "src/contracts/package-service.ts#RollbackInput (interface)", "RouteHandler": "src/contracts/http-server.ts#RouteHandler (type)", + "RunListResult": "src/contracts/automation-service.ts#RunListResult (interface)", "SEED_SETTLEMENT_SERVICE": "src/contracts/seed-settlement.ts#SEED_SETTLEMENT_SERVICE (const)", "SHARE_LINK_SERVICE": "src/contracts/share-link-service.ts#SHARE_LINK_SERVICE (const)", "SaveReportInput": "src/contracts/report-service.ts#SaveReportInput (interface)", diff --git a/packages/spec/src/api/automation-api.zod.test.ts b/packages/spec/src/api/automation-api.zod.test.ts index 8271c7fe3d8..9f1ac48e5e3 100644 --- a/packages/spec/src/api/automation-api.zod.test.ts +++ b/packages/spec/src/api/automation-api.zod.test.ts @@ -612,11 +612,13 @@ describe('ListRunsRequestSchema', () => { }); it('should accept full request', () => { + // `cursor` left this fixture when it was retired (#19543) — it is a + // `retiredKey()` tombstone now and any value raises. Its own cases are the + // block at the end of this describe. const result = ListRunsRequestSchema.parse({ name: 'my_flow', status: 'completed', limit: 5, - cursor: 'page2', }); expect(result.status).toBe('completed'); expect(result.limit).toBe(5); @@ -642,6 +644,58 @@ describe('ListRunsRequestSchema', () => { ).not.toThrow(); } }); + + // ── #19543 ─────────────────────────────────────────────────────────────── + // `cursor` retires; `limit` explicitly does NOT. Both halves are pinned, + // because the card that retired `cursor` arrived claiming `limit` was + // equally inert and the measurement said otherwise. + describe('`cursor` is retired, and the tombstone is what makes that audible', () => { + it('raises the PRESCRIPTION, not a generic unrecognized-key issue', () => { + // The negative half. A bare deletion would have been silent on this + // non-strict object — Zod strips an unknown key and parses clean — so + // the assertion that matters is the TEXT a caller is handed, which is + // the only upgrade channel a generated client ever reads. The `s` flag + // is house style: the message spans lines. + expect(() => ListRunsRequestSchema.parse({ name: 'my_flow', cursor: 'page2' })) + .toThrow(/`cursor`.*removed.*Delete the key.*`limit`.*STAYS/s); + }); + + it('refuses EVERY spelling, including the empty string the boundary used to pass through', () => { + // `?cursor=` reached the service verbatim before this retirement (the + // runtime pinned it as a preservation row), so the empty string is the + // one value a caller is most likely to still be sending. + for (const value of ['page2', '', 'n_007']) { + expect( + () => ListRunsRequestSchema.parse({ name: 'my_flow', cursor: value }), + `cursor=${JSON.stringify(value)} parsed instead of raising the tombstone`, + ).toThrow(/removed/); + } + }); + + it('leaves no `cursor` behind on a request that omits it', () => { + // The positive half for the non-strict strip path: the tombstone must + // not materialize a key of its own onto a clean parse. + const result = ListRunsRequestSchema.parse({ name: 'my_flow' }); + expect(result).not.toHaveProperty('cursor'); + }); + + it('⛔ does NOT retire `limit`, and keeps its `.default(20)`', () => { + // The over-block guard. #17667 retired `limit` AND `cursor` together on + // the sibling `/packages` door because neither was read there; the + // ruling for THIS door (decision batch #204 item 2, letter C) says that + // does not transfer, because here `limit` is read end to end — the + // runtime boundary takes its 1..100 bounds off this very declaration + // and the engine spends it as the run store's history window. A sweep + // that "finishes the job" by deleting it fails here. + const bare = ListRunsRequestSchema.parse({ name: 'my_flow' }); + expect(bare.limit).toBe(20); + expect(() => ListRunsRequestSchema.parse({ name: 'my_flow', limit: 50 })).not.toThrow(); + expect(ListRunsRequestSchema.parse({ name: 'my_flow', limit: 50 }).limit).toBe(50); + // The declared range the boundary reads back off this schema. + expect(() => ListRunsRequestSchema.parse({ name: 'my_flow', limit: 0 })).toThrow(); + expect(() => ListRunsRequestSchema.parse({ name: 'my_flow', limit: 101 })).toThrow(); + }); + }); }); describe('ListRunsResponseSchema', () => { diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index 6d74947a32d..2c987b97c55 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -35,6 +35,7 @@ import { ExecutionLogSchema, ExecutionStatus, FlowRunSummarySchema } from '../au * Path parameters for flow-level operations. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const AutomationFlowPathParamsSchema = lazySchema(() => z.object({ name: z.string().describe('Flow machine name (snake_case)'), })); @@ -507,9 +508,52 @@ export type ToggleFlowResponseParsed = z.infer; // 9. List Runs (GET /api/automation/:name/runs) // ========================================== +/** + * `cursor` retires alone — `limit` is NOT part of this retirement (#19543). + * + * Tombstoned rather than deleted for the ADR-0104 reason these request schemas + * keep paying for: this object is not `.strict()`, so a bare deletion makes Zod + * SILENTLY STRIP whatever a generated client keeps sending — a clean parse and + * a parameter that never takes effect, which is this card's own defect moved + * one layer down. `retiredKey()` types the key as `never` (so `tsc` refuses it + * at the authoring site) and raises this text at parse time. + */ +const RUNS_LIST_CURSOR_REMOVED = + '`cursor` was removed from GET /api/automation/:name/runs in @objectstack/spec 17.5.0 ' + + '(ADR-0049 enforce-or-remove) — it was VALIDATED at the boundary and then read by nothing: ' + + 'the option reached the service and the engine never looked at it, no emit site has ever ' + + 'written the response half `nextCursor`, and the only ordering this door has is a required ' + + 'but non-unique `startedAt` timestamp that nothing ever minted a resume point from — so a ' + + 'caller looping "until the cursor runs out" re-read the first and only window forever, with ' + + 'no error. Delete the key. `limit` is the real window ' + + 'and STAYS: it is read end to end (boundary to service to store) and bounded to 1..100, so ' + + 'ask for a wider window instead of a next page. Read the response `hasMore` to learn whether ' + + 'the window was short — it is now COMPUTED from the engine rather than the constant `false` ' + + 'it used to be.'; + /** * Query parameters for listing execution runs. * + * ⭐ The contract this declaration is being held to: every key here is one the + * serving door — `handleAutomationRequest`'s `parts[1] === 'runs'` GET branch + * in `packages/runtime/src/domains/automation.ts` — actually reads, and every + * key that door reads is here. `status` (#7359) and `limit` (#7300 / #8054) + * are both read end to end; `cursor` was the one that never was, and #19543 + * retires it (maintainer ruling, decision batch #204 item 2, letter C). + * + * ⛔ `limit` is NOT a retirement candidate on this door and its `.default(20)` + * stays with it. It is read at the boundary (`parseIntegerParam`, bounds taken + * off this very declaration), forwarded to `IAutomationService`, and spent by + * the engine as the store's history window — the opposite of the `/packages` + * door, whose `limit` was decorative and retired with its `cursor` (#17667). + * The two doors looked identical and measured differently; ⛔ do not transfer + * that ruling here. + * + * ⛔ Never add a key here that the door does not read. A declared-and-ignored + * query parameter fails undetectably: the caller is answered `200` with the + * unfiltered set and nothing in the status, headers or body distinguishes that + * from a request served as asked. + * * @example GET /api/automation/approval_flow/runs?status=completed&limit=10 */ export const ListRunsRequestSchema = lazySchema(() => AutomationFlowPathParamsSchema.extend({ @@ -524,8 +568,7 @@ export const ListRunsRequestSchema = lazySchema(() => AutomationFlowPathParamsSc .describe('Filter by execution status'), limit: z.number().int().min(1).max(100).default(20) .describe('Maximum number of runs to return'), - cursor: z.string().optional() - .describe('Cursor for pagination'), + cursor: retiredKey(RUNS_LIST_CURSOR_REMOVED), })); export type ListRunsRequest = z.input; /** Post-parse shape of {@link ListRunsRequest} — defaults applied, transforms run (ADR-0122). */ @@ -538,8 +581,23 @@ export const ListRunsResponseSchema = lazySchema(() => BaseResponseSchema.extend data: z.object({ runs: z.array(ExecutionLogSchema).describe('Execution run logs'), total: z.number().int().optional().describe('Total matching runs'), + // Never emitted, and since #19543 retired the request half that is true by + // construction rather than merely unimplemented: with no `cursor` to send, + // nothing can ask for a page, so there is no next one to name. The key + // stays declared and OPTIONAL, which is honest — an absent optional key + // promises nothing. ⛔ Do not start minting one without a request-side way + // to spend it; that is letter A of the #19543 ruling, explicitly not taken. nextCursor: z.string().optional().describe('Cursor for the next page'), - hasMore: z.boolean().describe('Whether more runs are available'), + // [#19543] COMPUTED, never hard-coded. The door asks the engine for the + // page rather than the rows, and the engine answers whether its merged + // candidate set overflowed the caller's `limit`. It used to be a literal + // `false` shipped beside a list that had been truncated — a caller asking + // for one row was handed one row and told that was all of them. + hasMore: z.boolean().describe( + 'Whether more runs matched than this response carries — widen `limit` to see them. ' + + 'Under `status`, `false` means no further match within the scanned window rather than ' + + 'none at all: the window is taken before the filter is applied.', + ), }), })); export type ListRunsResponse = z.input; diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index a551cf4f6da..0e0c7e2a19f 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -544,6 +544,37 @@ export interface FlowRuntimeState { reason?: string; } +/** + * One window of execution runs, plus the truncation fact the window alone + * cannot carry (#19543). + * + * The sibling shape is `ExportJobListResult` (`contracts/export-service.ts`), + * and the difference from it is deliberate: there is ⛔ NO `nextCursor` here. + * Nothing on this door has ever minted a continuation token, the request half + * that would have spent one is a retired key, and a `nextCursor` no caller can + * send back is the same declared-and-unusable shape #19543 exists to close. + * `hasMore` is actionable without one — the caller widens `limit`, which this + * door does read, up to its declared maximum of 100. + */ +export interface RunListResult { + /** The runs this response carries — at most the requested `limit`. */ + runs: ExecutionLog[]; + /** + * Whether more runs matched the request than {@link RunListResult.runs} + * carries. ⛔ Never a hard-coded constant: an implementation establishes + * it by over-reading its sources, because `runs.length === limit` cannot + * tell a flow with exactly `limit` runs from one with far more. + * + * ⚠️ **Qualified under a status filter.** An implementation may take its + * window BEFORE narrowing by `status` — the reference one does, because + * its durable history source has no status slot — in which case `false` + * means "no further match inside the window that was scanned", ⛔ not "no + * further match exists". Unfiltered, it is exact. Read it as a floor on + * what a wider `limit` would reveal, never as a count of the whole set. + */ + hasMore: boolean; +} + export interface IAutomationService { /** * Execute a named flow or script @@ -637,15 +668,69 @@ export interface IAutomationService { * store it merges; a filter that sees only half the rows is the same class * of confident wrong answer as not filtering at all. * + * ⛔ `cursor` is GONE from these options (#19543, maintainer ruling, + * decision batch #204 item 2, letter C). It was declared here, forwarded + * from the HTTP boundary, and read by no implementation; the wire half is + * a `retiredKey()` tombstone on `ListRunsRequestSchema`. ⛔ Do not add it + * back without a response-side way to mint one — that is letter A of the + * same ruling, explicitly not taken. `limit` is untouched and is the real + * window: it is read end to end and every implementation must honour it. + * + * ⭐ Prefer {@link IAutomationService.listRunsPage} at a door that has to + * answer `hasMore`. This member reports the window's CONTENTS and cannot + * report whether anything was left outside it, so a door built on it alone + * has nothing honest to put in that field — which is exactly the defect + * #19543 closed, where the run-list door shipped a literal `hasMore: false` + * beside a list the engine had already truncated. + * * @param flowName - Flow name (snake_case) - * @param options - Filter and pagination options + * @param options - Filter and window options * @returns Array of execution logs */ listRuns?( flowName: string, - options?: { limit?: number; cursor?: string; status?: ExecutionStatus }, + options?: { limit?: number; status?: ExecutionStatus }, ): Promise; + /** + * List one WINDOW of execution runs, and say whether more were left + * outside it (#19543). + * + * The truncation half is the reason this member exists and is not a + * convenience wrapper over {@link IAutomationService.listRuns}: only the + * implementation knows whether its own window bit. `listRuns` answers with + * at most `limit` rows and a caller cannot tell a flow with exactly + * `limit` runs from one with ten thousand — the two are byte-identical on + * the wire. An implementation MUST therefore establish `hasMore` by + * over-reading its sources rather than by comparing `runs.length` to + * `limit`, which cannot distinguish those two cases. + * + * `hasMore` means: **more runs matched this request than this response + * carries**, so a caller that widens `limit` will see rows it has not seen. + * It is ⛔ NOT a promise of a next page — this door mints no cursor and + * there is nothing to send back — and it is ⛔ NOT a statement about runs + * the deployment's retention policy has already discarded; those do not + * exist any more and are not "more". + * + * ⚠️ And it is qualified under `status`: an implementation whose window is + * taken before the filter is applied can only answer about the rows it + * scanned, so a status-filtered `false` does not promise that no older run + * of that status exists. {@link RunListResult.hasMore} carries the full + * statement; ⛔ do not restate it more strongly at a call site. + * + * OPTIONAL, and its absence is a DECLARED degradation rather than a silent + * one: a door that needs `hasMore` answers `501` naming this member, and + * ⛔ never a `200` carrying a guess. + * + * @param flowName - Flow name (snake_case) + * @param options - Filter and window options + * @returns The window, plus whether more runs matched than it carries + */ + listRunsPage?( + flowName: string, + options?: { limit?: number; status?: ExecutionStatus }, + ): Promise; + /** * Get a single execution run by ID * @param runId - Execution run ID diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__ListRunsRequest__cursor.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__ListRunsRequest__cursor.ts new file mode 100644 index 00000000000..24c38ed4a86 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__ListRunsRequest__cursor.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #19543 — ADR-0049 enforce-or-remove (director seat, decision batch #204 +// item 2, maintainer 「204 同意」 2026-09-21, letter C for this door). The +// prescription is `RUNS_LIST_CURSOR_REMOVED` in `api/automation-api.zod.ts`. +// +// ⭐ `cursor` retires ALONE here, and the asymmetry with the sibling +// `/packages` retirement is the whole point of the ruling. On that door both +// `limit` and `cursor` were decorative, so both went (#17667, +// `api/ListInstalledPackagesRequest:limit` / `:cursor`). On THIS door `limit` +// is read end to end — boundary bounds check, service option, then the run +// store's history window — and the Console's flow-runs page sends it today, so +// retiring it would have been a regression rather than a narrowing. The card's +// own body called it "declared, never read"; that sentence is false and was +// measured false before this entry was written. +// +// What made `cursor` retirable is the response half: no emit site has ever +// written `nextCursor`, and the only ordering this door has is a required but +// non-unique `startedAt` timestamp that nothing ever minted a resume point +// from — so nothing could ever have minted a value for a caller to send back. +// A caller looping "until the cursor runs out" re-read the first and only +// window forever. +// +// Same registration shape as the `/packages` pair: major 18 (the removal ships +// on the 17.x line as a minor; the prescription lives at the major boundary +// where `migrate meta` users look), and NO D2 conversion, because a conversion +// rewrites an authored source or a stored `sys_metadata` row and this shape is +// HTTP-only — nobody authors a `ListRunsRequest` and nothing persists one. The +// D3 semantic entry `automation-runs-cursor-retired` carries the record to +// `spec-changes.json`, the generated upgrade guide and `os migrate meta`. +export const entry = 'api/ListRunsRequest:cursor'; diff --git a/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts new file mode 100644 index 00000000000..cf4445205f9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'automation-runs-cursor-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'api.listRuns cursor — the pagination query parameter of ' + + 'GET /api/automation/:name/runs declared by ListRunsRequestSchema, its slot on ' + + 'IAutomationService.listRuns, and its option on all three @objectstack/client run-list ' + + 'surfaces (automation.runs.list, automation.listRuns, environment().automation.listRuns). ' + + 'The limit parameter of the same door is NOT part of this retirement and is unchanged, ' + + 'default(20) included', + replacement: + 'a wider `limit` — this door does read it, bounded to 1..100, and it is spent as the run ' + + "store's history window. There is no replacement for `cursor` itself, deliberately: " + + 'nothing ever minted one, so no caller holds a value to carry over, and the response ' + + '`nextCursor` it would have paired with has never been emitted. Read the response ' + + '`hasMore` to learn whether the window was short — it is now computed from the engine ' + + 'rather than the constant `false` it used to be, so for the first time it answers the ' + + 'question a caller reaching for a cursor was actually asking', + reason: + 'ADR-0049 enforce-or-remove (director seat, decision batch #204 item 2, maintainer ' + + '「204 同意」 2026-09-21, letter C of three for this door; letter A — build a cursor ' + + 'protocol for a 100-row window — and letter B — retire the key and leave the ' + + '`hasMore` lie standing — were both considered and refused). `cursor` was declared on ' + + 'the request, VALIDATED at the boundary, forwarded into a `cursor?: string` slot on ' + + 'the service contract, and read by no implementation: the engine never looked at the ' + + 'option, and no emit site has ever written the response half `nextCursor`, so a caller ' + + 'looping until the cursor ran out re-read the first and only window forever with no ' + + 'error. ' + + '⭐ The `limit` half of this door was NOT retired, and the distinction is the ruling, ' + + 'not an oversight. The sibling `/packages` door retired its `limit` with its `cursor` ' + + '(#17667, decision batch #126 item 1) because nothing read it; the parent ruling ' + + 'explicitly does not transfer here. On this door `limit` is read end to end — the ' + + 'boundary enforces the declared 1..100 range off the schema itself, the service takes ' + + 'it as an option, and the engine spends it as `RunStore.listHistory`\'s window — and ' + + "the Console's flow-runs page sends it today. Retiring it would have been a " + + 'regression, and its `.default(20)` stays with it. ' + + 'The same card computes `hasMore`, which is the half a bare retirement would have left ' + + 'lying. `GET /api/automation/:name/runs` shipped a literal `hasMore: false` beside a ' + + 'list the engine had already truncated with `.slice(0, limit)`, so a caller asking for ' + + 'one row of a thousand was handed one row and told that was all of them. The engine ' + + 'now reports truncation to the door through a new optional contract member, ' + + '`IAutomationService.listRunsPage`, which returns `{ runs, hasMore }`: it over-reads ' + + 'its history source by exactly one row and compares the merged, filtered, ordered set ' + + 'to the caller\'s window. The over-read is what makes the answer sound — ' + + '`runs.length === limit` cannot tell a flow with exactly `limit` runs from one with ' + + 'ten thousand, and `RunStore.listHistory`\'s signature is deliberately unchanged ' + + 'because over-reading is expressible in the `limit` it already takes. ' + + 'There IS a tombstone: the request schema is non-strict, so a bare deletion would have ' + + 'made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and ' + + "a parameter that never takes effect, which is this card's own defect re-created one " + + 'layer down (ADR-0104). `cursor` is therefore a `retiredKey()`, typed `never` for tsc ' + + 'and raising the prescription at any parse, and is registered in ' + + 'RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an ' + + 'authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody ' + + 'authors a `ListRunsRequest` and nothing persists one. The `os migrate meta` house ' + + 'sentence is therefore correctly absent from the prescription. There is no ' + + '`acceptRetiredDefaultResidue` stage either: `cursor` carried no default, so it ' + + 'materialized into no artifact and there is no residue to accept. ' + + 'The SDK half is part of the retirement rather than a follow-up: `@objectstack/client` ' + + 'declared `cursor` and appended it on all three run-list surfaces, so retiring the key ' + + 'in the schema alone would have left the one generated client this repo ships typing it ' + + '`string` and sending it into a route that silently drops it — the ADR-0104 shape the ' + + 'tombstone exists to prevent, re-created one layer down. The same call was made when ' + + '#6361 retired the notifications `cursor`: the client dropped the option and recorded ' + + 'the removal in its docblock. ADR-0049 / ADR-0087, #19543.', + acceptanceCriteria: + 'No caller sends `cursor` to `GET /api/automation/:name/runs`, and that is true of every ' + + 'channel this repo ships rather than of the schema alone. Writing it on a ' + + '`ListRunsRequest` is a `tsc` error (the input type is `never`), and any value reaching a ' + + 'parse raises the prescription rather than a generic unrecognized-key issue. The option is ' + + 'gone from `IAutomationService.listRuns`, so an implementation can no longer declare a slot ' + + 'for it. ⭐ It is also gone from the SDK, which is the channel most callers actually reach ' + + 'this door through: `@objectstack/client` no longer declares `cursor` on ' + + '`automation.runs.list`, `automation.listRuns` or ' + + '`client.environment(id).automation.listRuns`, and no longer appends `?cursor=` on any of ' + + 'the three — so the key cannot be smuggled past the retired schema by an untyped caller. ' + + 'Without that half the retirement would have re-created its own defect one layer down: ' + + 'the schema typing the key `never` while the shipped client typed it `string` and sent it, ' + + 'silently dropped by a route that no longer reads it (ADR-0104). ' + + '⚠️ ONE wire behaviour CHANGES and must be verified as such, because it reverses a ' + + 'decision recorded under #7300: a repeated `?cursor=a&cursor=b` used to answer ' + + '`400 VALIDATION_FAILED` with a `details.fields[]` entry naming `cursor`, and now ' + + 'answers `200` with the key ignored like any other unrecognised query name. #7300 ' + + 'validated the key rather than deciding it, so that a future cursor implementation ' + + 'would not be the one to discover the type was unenforced; this ruling decides it ' + + 'instead — there will be no cursor implementation on this door — so the refusal would ' + + 'be validating a key the contract no longer has. This route declares no closed query ' + + 'set (AGENTS.md route-ownership rule 5), so an unrecognised name has never been refused here ' + + 'on its own account. ' + + '⚠️ `hasMore` also changes, from a constant to an answer: a request whose window is ' + + 'shorter than the matching run set now receives `hasMore: true` where it previously ' + + 'received `false`. A caller that treated `false` as "this is the whole history" was ' + + 'always wrong and is now told so. ⚠️ Read the new `false` with one qualification: ' + + 'unfiltered it is exact, but under `?status=` it means "no further match inside the ' + + 'window that was scanned" rather than "none exists", because the durable history ' + + 'source has no status slot and the window is taken before the filter is applied. ' + + 'Pushing the filter down is a store-contract change this card did not scope. ' + + '`nextCursor` stays absent — nothing mints one — and ' + + '`limit` behaves exactly as it did, including its `.default(20)`. ' + + 'A deployment whose automation service does not implement `listRunsPage` answers `501` ' + + 'naming the member, and ⛔ never a `200` carrying an invented `hasMore`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 93dc4cea159..8f298d9213e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5799,6 +5799,109 @@ 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-runs-cursor-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'api.listRuns cursor — the pagination query parameter of ' + + 'GET /api/automation/:name/runs declared by ListRunsRequestSchema, its slot on ' + + 'IAutomationService.listRuns, and its option on all three @objectstack/client run-list ' + + 'surfaces (automation.runs.list, automation.listRuns, environment().automation.listRuns). ' + + 'The limit parameter of the same door is NOT part of this retirement and is unchanged, ' + + 'default(20) included', + replacement: + 'a wider `limit` — this door does read it, bounded to 1..100, and it is spent as the run ' + + "store's history window. There is no replacement for `cursor` itself, deliberately: " + + 'nothing ever minted one, so no caller holds a value to carry over, and the response ' + + '`nextCursor` it would have paired with has never been emitted. Read the response ' + + '`hasMore` to learn whether the window was short — it is now computed from the engine ' + + 'rather than the constant `false` it used to be, so for the first time it answers the ' + + 'question a caller reaching for a cursor was actually asking', + reason: + 'ADR-0049 enforce-or-remove (director seat, decision batch #204 item 2, maintainer ' + + '「204 同意」 2026-09-21, letter C of three for this door; letter A — build a cursor ' + + 'protocol for a 100-row window — and letter B — retire the key and leave the ' + + '`hasMore` lie standing — were both considered and refused). `cursor` was declared on ' + + 'the request, VALIDATED at the boundary, forwarded into a `cursor?: string` slot on ' + + 'the service contract, and read by no implementation: the engine never looked at the ' + + 'option, and no emit site has ever written the response half `nextCursor`, so a caller ' + + 'looping until the cursor ran out re-read the first and only window forever with no ' + + 'error. ' + + '⭐ The `limit` half of this door was NOT retired, and the distinction is the ruling, ' + + 'not an oversight. The sibling `/packages` door retired its `limit` with its `cursor` ' + + '(#17667, decision batch #126 item 1) because nothing read it; the parent ruling ' + + 'explicitly does not transfer here. On this door `limit` is read end to end — the ' + + 'boundary enforces the declared 1..100 range off the schema itself, the service takes ' + + 'it as an option, and the engine spends it as `RunStore.listHistory`\'s window — and ' + + "the Console's flow-runs page sends it today. Retiring it would have been a " + + 'regression, and its `.default(20)` stays with it. ' + + 'The same card computes `hasMore`, which is the half a bare retirement would have left ' + + 'lying. `GET /api/automation/:name/runs` shipped a literal `hasMore: false` beside a ' + + 'list the engine had already truncated with `.slice(0, limit)`, so a caller asking for ' + + 'one row of a thousand was handed one row and told that was all of them. The engine ' + + 'now reports truncation to the door through a new optional contract member, ' + + '`IAutomationService.listRunsPage`, which returns `{ runs, hasMore }`: it over-reads ' + + 'its history source by exactly one row and compares the merged, filtered, ordered set ' + + 'to the caller\'s window. The over-read is what makes the answer sound — ' + + '`runs.length === limit` cannot tell a flow with exactly `limit` runs from one with ' + + 'ten thousand, and `RunStore.listHistory`\'s signature is deliberately unchanged ' + + 'because over-reading is expressible in the `limit` it already takes. ' + + 'There IS a tombstone: the request schema is non-strict, so a bare deletion would have ' + + 'made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and ' + + "a parameter that never takes effect, which is this card's own defect re-created one " + + 'layer down (ADR-0104). `cursor` is therefore a `retiredKey()`, typed `never` for tsc ' + + 'and raising the prescription at any parse, and is registered in ' + + 'RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an ' + + 'authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody ' + + 'authors a `ListRunsRequest` and nothing persists one. The `os migrate meta` house ' + + 'sentence is therefore correctly absent from the prescription. There is no ' + + '`acceptRetiredDefaultResidue` stage either: `cursor` carried no default, so it ' + + 'materialized into no artifact and there is no residue to accept. ' + + 'The SDK half is part of the retirement rather than a follow-up: `@objectstack/client` ' + + 'declared `cursor` and appended it on all three run-list surfaces, so retiring the key ' + + 'in the schema alone would have left the one generated client this repo ships typing it ' + + '`string` and sending it into a route that silently drops it — the ADR-0104 shape the ' + + 'tombstone exists to prevent, re-created one layer down. The same call was made when ' + + '#6361 retired the notifications `cursor`: the client dropped the option and recorded ' + + 'the removal in its docblock. ADR-0049 / ADR-0087, #19543.', + acceptanceCriteria: + 'No caller sends `cursor` to `GET /api/automation/:name/runs`, and that is true of every ' + + 'channel this repo ships rather than of the schema alone. Writing it on a ' + + '`ListRunsRequest` is a `tsc` error (the input type is `never`), and any value reaching a ' + + 'parse raises the prescription rather than a generic unrecognized-key issue. The option is ' + + 'gone from `IAutomationService.listRuns`, so an implementation can no longer declare a slot ' + + 'for it. ⭐ It is also gone from the SDK, which is the channel most callers actually reach ' + + 'this door through: `@objectstack/client` no longer declares `cursor` on ' + + '`automation.runs.list`, `automation.listRuns` or ' + + '`client.environment(id).automation.listRuns`, and no longer appends `?cursor=` on any of ' + + 'the three — so the key cannot be smuggled past the retired schema by an untyped caller. ' + + 'Without that half the retirement would have re-created its own defect one layer down: ' + + 'the schema typing the key `never` while the shipped client typed it `string` and sent it, ' + + 'silently dropped by a route that no longer reads it (ADR-0104). ' + + '⚠️ ONE wire behaviour CHANGES and must be verified as such, because it reverses a ' + + 'decision recorded under #7300: a repeated `?cursor=a&cursor=b` used to answer ' + + '`400 VALIDATION_FAILED` with a `details.fields[]` entry naming `cursor`, and now ' + + 'answers `200` with the key ignored like any other unrecognised query name. #7300 ' + + 'validated the key rather than deciding it, so that a future cursor implementation ' + + 'would not be the one to discover the type was unenforced; this ruling decides it ' + + 'instead — there will be no cursor implementation on this door — so the refusal would ' + + 'be validating a key the contract no longer has. This route declares no closed query ' + + 'set (AGENTS.md route-ownership rule 5), so an unrecognised name has never been refused here ' + + 'on its own account. ' + + '⚠️ `hasMore` also changes, from a constant to an answer: a request whose window is ' + + 'shorter than the matching run set now receives `hasMore: true` where it previously ' + + 'received `false`. A caller that treated `false` as "this is the whole history" was ' + + 'always wrong and is now told so. ⚠️ Read the new `false` with one qualification: ' + + 'unfiltered it is exact, but under `?status=` it means "no further match inside the ' + + 'window that was scanned" rather than "none exists", because the durable history ' + + 'source has no status slot and the window is taken before the filter is applied. ' + + 'Pushing the filter down is a store-contract change this card did not scope. ' + + '`nextCursor` stays absent — nothing mints one — and ' + + '`limit` behaves exactly as it did, including its `.default(20)`. ' + + 'A deployment whose automation service does not implement `listRunsPage` answers `501` ' + + 'naming the member, and ⛔ never a `200` carrying an invented `hasMore`.', + }, { id: 'autonumber-default-unique-organization', surface: '`fields..unique` on a `type: \'autonumber\'` field when the author OMITS the key — ' @@ -13295,6 +13398,35 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `api/ListNotificationsRequest:cursor` (#6361) already took for the same // shape one route over. 'api/ListInstalledPackagesRequest:limit', + // #19543 — ADR-0049 enforce-or-remove (director seat, decision batch #204 + // item 2, maintainer 「204 同意」 2026-09-21, letter C for this door). The + // prescription is `RUNS_LIST_CURSOR_REMOVED` in `api/automation-api.zod.ts`. + // + // ⭐ `cursor` retires ALONE here, and the asymmetry with the sibling + // `/packages` retirement is the whole point of the ruling. On that door both + // `limit` and `cursor` were decorative, so both went (#17667, + // `api/ListInstalledPackagesRequest:limit` / `:cursor`). On THIS door `limit` + // is read end to end — boundary bounds check, service option, then the run + // store's history window — and the Console's flow-runs page sends it today, so + // retiring it would have been a regression rather than a narrowing. The card's + // own body called it "declared, never read"; that sentence is false and was + // measured false before this entry was written. + // + // What made `cursor` retirable is the response half: no emit site has ever + // written `nextCursor`, and the only ordering this door has is a required but + // non-unique `startedAt` timestamp that nothing ever minted a resume point + // from — so nothing could ever have minted a value for a caller to send back. + // A caller looping "until the cursor runs out" re-read the first and only + // window forever. + // + // Same registration shape as the `/packages` pair: major 18 (the removal ships + // on the 17.x line as a minor; the prescription lives at the major boundary + // where `migrate meta` users look), and NO D2 conversion, because a conversion + // rewrites an authored source or a stored `sys_metadata` row and this shape is + // HTTP-only — nobody authors a `ListRunsRequest` and nothing persists one. The + // D3 semantic entry `automation-runs-cursor-retired` carries the record to + // `spec-changes.json`, the generated upgrade guide and `os migrate meta`. + 'api/ListRunsRequest:cursor', // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, // executing the #14369 liveness census (15 `dead` rows across the `crud` / // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest`