Skip to content

Commit 2af301e

Browse files
committed
fix(spec,runtime,service-automation,client): GET /automation/:name/runs?status= filters instead of being dropped (#7359)
Fixes #7359. Triage's option 1 (ENFORCE), as ruled by the PM. ## What was measured Relocated by content on the branch point (afdc6ea), not by triage's `8a9c079` line numbers. All three layers confirmed still defective: - `packages/spec/src/api/automation-api.zod.ts:274` — `ListRunsRequestSchema` declares `status: z.enum([...8 members]).optional()`, described as "Filter by execution status". - `packages/spec/src/contracts/automation-service.ts:416` — `listRuns?(flowName, options?: { limit?: number; cursor?: string })`. No slot. - `packages/runtime/src/domains/automation.ts:462` — the runs branch built `{ limit, cursor }` only. `status` never left the HTTP layer. So `?status=failed` was dropped silently and the caller got 200 + every run of the flow, capped by `limit`. A monitoring caller paging for failures read the first 20 runs of any status and concluded those were the failures. The existing test `automation-runs-query-validation.test.ts` carried an explicit pin of that behaviour ("`?status=failed` is ignored, not refused"), written by #7300 which deliberately declined to decide between honouring and retiring the key. That pin is SUPERSEDED here — replaced by cases asserting the opposite on the same input, not deleted silently. ## What changed, and why 1. **Contract** (`contracts/automation-service.ts`) — `status?: ExecutionStatus` added to the `listRuns` option. One optional key, no breaking change. 2. **Boundary** (`runtime/src/domains/automation.ts`, runs branch ONLY) — reads the parameter through the shared `query-param.ts` helper, as directed. That module had no enum gate, so this adds `parseEnumParam` alongside the existing boolean/integer/string ones rather than hand-rolling a comparison at the call site. It refuses in the house shape: `validationFailure` -> 400 `VALIDATION_FAILED` (ADR-0112) + `details.fields[]`. No new error vocabulary — ADR-0114's closed catalog already carries `invalid_option` ("not a member of the field's declared options") for a non-member, and `invalid_type` for a value that was never a single string (repeated `?status=a&status=b`, structured `?status[$ne]=x`), which is the mapping `parseStringParam` already makes for that same condition. 3. **Engine** (`service-automation/src/engine.ts`) — filters at the MERGE point. ## Which store each filter arm covers The dispatch called out that a one-armed filter is the same class of wrong answer as the bug. It is applied once, to the merged map, which covers both: - in-memory ring buffer (`this.executionLogs`) — the live half; - durable rows (`store.listHistory`) — the half that survives a restart. Filtering after the merge rather than on each arm is deliberate and load-bearing for a second reason I found while writing it: `executionLogs` holds MORE THAN ONE entry per run id (a run that pauses appends 'paused', then its terminal entry), and the merge is what collapses them to the freshest. Filtering the arms before that collapse drops the terminal entry for `?status=paused` and lets the stale 'paused' one survive — every approval/screen/wait run that had since completed would report itself as still paused. That is precisely the defect `run-history.test.ts`'s "latest entry wins" block pins for `getRun`, and I very nearly re-introduced it one method over; my first draft filtered each arm. There is now a test for it. KNOWN, DOCUMENTED LIMIT (not fixed, not silently narrowed): the durable arm's window is still `listHistory(flowName, limit)` — that store method has no status slot, so the filter is applied to the rows that come back rather than pushed down. A status filter can therefore return fewer than `limit` matches while older matching rows exist. This is the merge's pre-existing shape (durable was already capped at `limit` before the sort-and-slice); closing it properly is a store-contract change and belongs in its own card. What it never does is return a run of another status. Stated in code, changeset, and here rather than left for someone to discover. ## How I proved the tests can fail Every new test was mutated to red and reverted. Six mutations: | mutation | red | |-------------------------------------------------|----------------------------------------| | handler drops `status` from the options object | 20 runtime tests | | `parseStringParam` instead of `parseEnumParam` | 6 runtime (refusals + empty spelling) | | engine ignores `status` entirely | 4 engine tests | | engine filters the IN-MEMORY arm only | 3 engine (incl. the durable one) | | engine filters the DURABLE arm only | 3 engine (incl. the in-memory one) | | engine filter made non-optional | preservation test + 4 pre-existing | | schema re-lists members, drops `retrying` | the new spec drift pin | The one-armed mutations are the two that matter for the dispatch's warning, and both are caught. This also FOUND A WEAK TEST: my first "merged listing" case used a fixture whose runs were all `failed`, so it passed even with the filter removed entirely. It now seeds one status per store (a durable failure + a live success under one flow name) and asserts each answer comes from a different store and neither leaks the other's run. It fails under all three engine mutations. ## Decisions the dispatch did not specify 1. **Empty `?status=` means NO filter, not a 400.** `parseIntegerParam`'s falsy gate, not `parseBooleanParam`'s refusal. Reason: the prior answers differ in kind. `?read=` used to serve the wrong HALF of the inbox, so refusing it was a strict improvement; `?status=` already served every run, which is exactly what "no filter" means, so it had a defensible answer that must not become a new 400. It is also what an "All statuses" `<select>` submits. 2. **`ListRunsRequestSchema` now references `ExecutionStatus`** instead of re-listing its eight members inline. I had written a comment claiming the boundary and the wire could not drift; that was not true as written, since the schema held a hand-copied duplicate. Made it true rather than softening the comment. Identical members, so no wire change — plus a spec test pinning that every `ExecutionStatus` member parses, which fails if the lists diverge. 3. **The typed client can send it** (`packages/client`). The issue names the SDK gap as the reason nothing had tripped over this. Leaving it out would make the enforced filter reachable only from raw HTTP, while the Runs view triage cited as the real consumer goes through this client. Additive optional param on both `automation.listRuns` and `automation.runs.list`. This is a fourth package beyond the three the dispatch listed — flagging it explicitly; it is additive and does not touch `domains/automation.ts`, so it carries no conflict risk with #7360. ## Constraints honoured - Descriptor routes (`GET /actions`, `GET /connectors`) NOT touched — #7360's diff in this file will not conflict; my change is confined to the runs branch. - `content/docs/releases/**` not touched. Changeset added instead. - Worktree-first (`../objectstack-7359`); no `git stash` at any point. ## Gates - `pnpm lint` — clean - `pnpm typecheck` — 126/126 tasks - `packages/spec` — 373 files, 9790 tests - `packages/runtime` — 121 files, 1939 tests - `packages/services/service-automation` — 73 files, 895 tests - `packages/client` — 21 files, 279 tests Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WPDMabeKZnjXeoG41wb4rK
1 parent afdc6ea commit 2af301e

10 files changed

Lines changed: 535 additions & 33 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/client": minor
4+
"@objectstack/runtime": patch
5+
"@objectstack/service-automation": patch
6+
---
7+
8+
fix(spec,runtime,service-automation): `GET /automation/:name/runs?status=` filters the runs instead of being dropped (#7359)
9+
10+
`ListRunsRequestSchema` has always declared a `status` filter on
11+
`GET /api/automation/:name/runs` — `z.enum([...the eight ExecutionStatus
12+
members]).optional()`, described as "Filter by execution status". Nothing read
13+
it. It had no slot on `IAutomationService.listRuns`, whose options were
14+
`{ limit?, cursor? }`, and the runtime handler never built it into the object it
15+
forwarded, so the parameter was dropped at the HTTP boundary and the caller was
16+
answered **200 with every run of the flow**, capped by `limit`.
17+
18+
That is worse than an error, because the answer looks like the one that was
19+
asked for: a monitoring caller paging `?status=failed` reads the first 20 runs
20+
of any status and concludes those are the failures. Exposure was raw HTTP,
21+
generated clients, and anything authored against the OpenAPI surface — the typed
22+
SDK could not send the parameter at all, which is why nothing had tripped over
23+
it. #7300 fixed this route's two *coerced* parameters and deliberately preserved
24+
the ignore-the-key behaviour rather than decide between honouring and retiring
25+
the third; this change takes the enforce route (ADR-0049), so the declared
26+
surface is now true.
27+
28+
**The filter is honoured across both stores.** `AutomationEngine.listRuns`
29+
serves the Runs view by merging an in-memory ring buffer with the durable run
30+
history it reads back from the store. The narrowing is applied to the merged
31+
result, so both halves are covered: filtering only the buffer would answer "no
32+
failures" for a flow whose failures are all in durable history — i.e. after any
33+
restart, which is exactly when someone asks — and filtering only the durable
34+
rows would hide the live ones. Applying it after the merge also means each run
35+
is matched on its **resolved** status: the buffer holds more than one entry per
36+
run id (a run that pauses appends `paused`, then its terminal entry), and
37+
narrowing before the collapse would have let a stale `paused` entry outlive the
38+
terminal one, so every approval/screen/wait run that had since completed would
39+
report itself as still paused.
40+
41+
The durable arm's window is unchanged: `listHistory(flowName, limit)` has no
42+
status slot, so the filter is applied to the rows that come back rather than
43+
pushed down, and a status filter can therefore return fewer than `limit` matches
44+
while older ones exist. That is this merge's pre-existing shape — durable rows
45+
were already capped at `limit` before the sort-and-slice — and closing it is a
46+
store-contract change. What it never does is return a run of another status.
47+
48+
**An undeclared status is now refused, not silently widened.** Once the filter
49+
is honoured, a value outside the set has no safe reading: `?status=faild` cannot
50+
mean "no filter", and serving the empty list is no better, because "no runs are
51+
`faild`" and "no runs failed" read identically to a caller who cannot see their
52+
own typo. The check goes through the shared `query-param` module this route
53+
already consumes with `/notifications`, as a new `parseEnumParam` gate, and
54+
refuses in the house shape — `400` `VALIDATION_FAILED` (ADR-0112) with a
55+
`details.fields[]` entry carrying ADR-0114's existing `invalid_option`
56+
("not a member of the field's declared options"); a value that was never a
57+
single string at all — a repeated `?status=a&status=b`, a structured
58+
`?status[$ne]=x` — gets `invalid_type`, the same mapping the module's string
59+
gate already makes. No new error vocabulary. The accepted members are read from
60+
the spec's own `ExecutionStatus` enum, the one `ListRunsRequestSchema` is built
61+
from, so the wire's declared set and the boundary's accepted set cannot drift.
62+
63+
**The typed client can now send it.** `client.automation.listRuns(flow, {
64+
status })` — both the `automation.listRuns` alias and `automation.runs.list` —
65+
takes the filter as an optional `ExecutionStatus`, additively. It could not send
66+
the parameter at all before, which is the reason nothing had tripped over the
67+
server-side gap; leaving it out would have made the enforced filter reachable
68+
only from raw HTTP, and the Runs view that wants it goes through this client.
69+
70+
**Nothing that had a defensible answer changes.** An absent `status` still
71+
returns every run, exactly as before. So does the empty spelling `?status=`
72+
unlike `?read=` on the notifications inbox, which used to serve the wrong *half*
73+
of the result, `?status=` already served precisely what "no filter" means, and
74+
it is what an "All statuses" `<select>` submits. `limit` and `cursor` are
75+
untouched, including out-of-range values, which remain the service's business.

packages/client/src/index.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import type {
8383
ApprovalStatus,
8484
ApprovalDecisionResult,
8585
} from '@objectstack/spec/contracts';
86+
import type { ExecutionStatus } from '@objectstack/spec/automation';
8687
import { Logger, createLogger } from '@objectstack/core/logger';
8788
import { RealtimeAPI } from './realtime-api';
8889

@@ -3130,12 +3131,17 @@ export class ObjectStackClient {
31303131
/** Alias for `automation.runs.list`. */
31313132
listRuns: async <T = any>(
31323133
flowName: string,
3133-
opts?: { limit?: number; cursor?: string },
3134+
opts?: { limit?: number; cursor?: string; status?: ExecutionStatus },
31343135
): Promise<T> => {
31353136
const route = this.getRoute('automation');
31363137
const params = new URLSearchParams();
31373138
if (opts?.limit != null) params.set('limit', String(opts.limit));
31383139
if (opts?.cursor) params.set('cursor', opts.cursor);
3140+
// [#7359] The route's declared `status` filter, now that the boundary
3141+
// honours it instead of dropping it. Until this card the typed client
3142+
// could not send it at all — which is why nothing had tripped over the
3143+
// server-side gap.
3144+
if (opts?.status) params.set('status', opts.status);
31393145
const qs = params.toString();
31403146
const res = await this.fetch(
31413147
`${this.baseUrl}${route}/${encodeURIComponent(flowName)}/runs${qs ? `?${qs}` : ''}`,
@@ -5261,14 +5267,16 @@ export class ScopedProjectClient {
52615267
});
52625268
return this.parent._unwrap<T>(res);
52635269
},
5264-
/** List recent runs for a flow. */
5270+
/** List recent runs for a flow, optionally narrowed to one status. */
52655271
listRuns: async <T = any>(
52665272
flowName: string,
5267-
opts?: { limit?: number; cursor?: string },
5273+
opts?: { limit?: number; cursor?: string; status?: ExecutionStatus },
52685274
): Promise<T> => {
52695275
const params = new URLSearchParams();
52705276
if (opts?.limit != null) params.set('limit', String(opts.limit));
52715277
if (opts?.cursor) params.set('cursor', opts.cursor);
5278+
// [#7359] — see the sibling `listRuns` alias above.
5279+
if (opts?.status) params.set('status', opts.status);
52725280
const qs = params.toString();
52735281
const res = await this.parent._fetch(
52745282
this.url(`/automation/${encodeURIComponent(flowName)}/runs${qs ? `?${qs}` : ''}`),

packages/runtime/src/domains/automation-runs-query-validation.test.ts

Lines changed: 122 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* #7300 — `GET /api/v1/automation/:name/runs`'s coerced query parameters.
4+
* #7300 / #7359 — `GET /api/v1/automation/:name/runs`'s query parameters, at
5+
* the boundary that reads them.
6+
*
7+
* #7300 (below) closed the two parameters this handler already forwarded but
8+
* COERCED. #7359 closed the third, which is the same 200-with-the-wrong-answer
9+
* arrived at from the opposite direction: `status` was declared by
10+
* `ListRunsRequestSchema`, had no slot on `IAutomationService.listRuns`, and
11+
* was never built into the handler's option object — so `?status=failed` was
12+
* dropped here in silence and the caller was answered with EVERY run of the
13+
* flow. #7300 deliberately pinned that ignore-the-key behaviour rather than
14+
* decide it; #7359 took the enforce route, so that one pin is superseded here
15+
* by cases asserting the opposite on the same input.
516
*
617
* The filed defect is character-for-character #6928's, one file over:
718
* `{ limit: query.limit ? Number(query.limit) : undefined, cursor: query.cursor }`.
@@ -132,6 +143,60 @@ describe("#7300 — the same probe on this route's other passed-through paramete
132143
});
133144
});
134145

146+
describe('#7359 — a `?status=` outside the declared set is refused, not silently widened', () => {
147+
it.each([
148+
['a typo', 'faild'],
149+
['right word, wrong case', 'FAILED'],
150+
['a status of a neighbouring vocabulary', 'success'],
151+
['empty-ish but not a member', ' '],
152+
])('refuses ?status=%s with 400 VALIDATION_FAILED', async (_label, raw) => {
153+
// Once the filter is honoured there is no safe reading left for a value
154+
// outside the set. `?status=faild` cannot mean "no filter" — the caller
155+
// plainly asked to narrow — and serving the empty list is no better,
156+
// because "no runs are `faild`" and "no runs failed" read identically to
157+
// a caller who cannot see their own typo. Both are a monitoring surface
158+
// answering "you have no failures" with confidence.
159+
const { details, status, listRuns } = await refusalFor({ status: raw });
160+
161+
expect(details?.code).toBe('VALIDATION_FAILED');
162+
expect(status).toBe(400);
163+
// ADR-0114's closed catalog already carries the constraint this
164+
// violates — `invalid_option`, "not a member of the field's declared
165+
// options". No new error vocabulary is minted for it.
166+
expect(details?.fields).toEqual([
167+
{ field: 'status', code: 'invalid_option', message: expect.stringContaining('`status`') },
168+
]);
169+
expect(listRuns).not.toHaveBeenCalled();
170+
});
171+
172+
it.each([
173+
['repeated parameter', ['failed', 'completed']],
174+
['structured', { $ne: 'failed' }],
175+
['numeric', 7],
176+
])('refuses ?status=%s — it was never a single string (invalid_type)', async (_label, raw) => {
177+
// The same mapping `parseStringParam` makes for the same condition: a
178+
// repeated `?status=failed&status=completed` arrives as an ARRAY, and a
179+
// filter is not a set on this wire. `String([...])` would have made it
180+
// the single value `'failed,completed'`, matching nothing.
181+
const { details, status, listRuns } = await refusalFor({ status: raw });
182+
183+
expect(details?.code).toBe('VALIDATION_FAILED');
184+
expect(status).toBe(400);
185+
expect(details?.fields).toEqual([
186+
{ field: 'status', code: 'invalid_type', message: expect.stringContaining('`status`') },
187+
]);
188+
expect(listRuns).not.toHaveBeenCalled();
189+
});
190+
191+
it('names the declared members in the message, and caps the echoed value', async () => {
192+
const { message } = await refusalFor({ status: 'z'.repeat(500) });
193+
194+
expect(message).toContain('failed'); // the caller is told what IS accepted
195+
expect(message).toContain('pending');
196+
expect(message.length).toBeLessThan(300);
197+
});
198+
});
199+
135200
describe('#7300 — every value that had a defensible answer keeps it', () => {
136201
async function listWith(query: Record<string, unknown> | undefined) {
137202
const { dispatcher, listRuns } = makeDispatcher();
@@ -141,28 +206,28 @@ describe('#7300 — every value that had a defensible answer keeps it', () => {
141206

142207
it.each([
143208
// [label, query, the exact options object `listRuns` must receive]
144-
['?limit=20', { limit: '20' }, { limit: 20, cursor: undefined }],
145-
['?limit=1 (the low boundary)', { limit: '1' }, { limit: 1, cursor: undefined }],
146-
['?limit=100 (the declared high boundary)', { limit: '100' }, { limit: 100, cursor: undefined }],
209+
['?limit=20', { limit: '20' }, { limit: 20, cursor: undefined, status: undefined }],
210+
['?limit=1 (the low boundary)', { limit: '1' }, { limit: 1, cursor: undefined, status: undefined }],
211+
['?limit=100 (the declared high boundary)', { limit: '100' }, { limit: 100, cursor: undefined, status: undefined }],
147212
// Out of RANGE is not out of DOMAIN. `ListRunsRequestSchema` bounds
148213
// `limit` to 1..100 and the engine slices by whatever it is handed;
149214
// neither answer is this boundary's to change, so both still arrive.
150-
['?limit=1000 (over the declared range)', { limit: '1000' }, { limit: 1000, cursor: undefined }],
151-
['?limit=-5 (under it)', { limit: '-5' }, { limit: -5, cursor: undefined }],
215+
['?limit=1000 (over the declared range)', { limit: '1000' }, { limit: 1000, cursor: undefined, status: undefined }],
216+
['?limit=-5 (under it)', { limit: '-5' }, { limit: -5, cursor: undefined, status: undefined }],
152217
// Falsy spellings meant "no limit here" before this gate existed and
153218
// still do — they must not become a new 400. `'0'` is NOT one of them:
154219
// the string is truthy, so `query.limit ? Number(query.limit) : …` read
155220
// it as the number `0` and passed it on, and that is preserved too.
156-
['?limit= (empty)', { limit: '' }, { limit: undefined, cursor: undefined }],
157-
['?limit=0', { limit: '0' }, { limit: 0, cursor: undefined }],
158-
['limit: 0 (in-process number)', { limit: 0 }, { limit: undefined, cursor: undefined }],
159-
['limit: null', { limit: null }, { limit: undefined, cursor: undefined }],
160-
['no parameters at all', {}, { limit: undefined, cursor: undefined }],
221+
['?limit= (empty)', { limit: '' }, { limit: undefined, cursor: undefined, status: undefined }],
222+
['?limit=0', { limit: '0' }, { limit: 0, cursor: undefined, status: undefined }],
223+
['limit: 0 (in-process number)', { limit: 0 }, { limit: undefined, cursor: undefined, status: undefined }],
224+
['limit: null', { limit: null }, { limit: undefined, cursor: undefined, status: undefined }],
225+
['no parameters at all', {}, { limit: undefined, cursor: undefined, status: undefined }],
161226
// A cursor is opaque: every string passes through VERBATIM, including
162227
// the empty one, exactly as the raw passthrough did.
163-
['?cursor=n_007', { cursor: 'n_007' }, { limit: undefined, cursor: 'n_007' }],
164-
['?cursor= (empty)', { cursor: '' }, { limit: undefined, cursor: '' }],
165-
['both together', { limit: '5', cursor: 'n_007' }, { limit: 5, cursor: 'n_007' }],
228+
['?cursor=n_007', { cursor: 'n_007' }, { limit: undefined, cursor: 'n_007', status: undefined }],
229+
['?cursor= (empty)', { cursor: '' }, { limit: undefined, cursor: '', status: undefined }],
230+
['both together', { limit: '5', cursor: 'n_007' }, { limit: 5, cursor: 'n_007', status: undefined }],
166231
])('%s answers 200 and reaches the service unchanged', async (_label, query, expected) => {
167232
const { result, listRuns } = await listWith(query);
168233

@@ -180,15 +245,52 @@ describe('#7300 — every value that had a defensible answer keeps it', () => {
180245
expect(listRuns).toHaveBeenCalledWith('welcome_flow', undefined);
181246
});
182247

183-
it('leaves an unknown query key alone — `?status=failed` is ignored, not refused (#6361)', async () => {
184-
// `ListRunsRequestSchema` declares a `status` filter that this handler
185-
// has never read, so the key is silently ignored today. Refusing unknown
186-
// keys — or starting to honour this one — is a separate decision with
187-
// its own blast radius; this change takes neither.
248+
// ── #7359 ────────────────────────────────────────────────────────────────
249+
// The block above used to end with a case asserting that `?status=failed`
250+
// was IGNORED — #7300 deliberately preserved that, since choosing between
251+
// honouring and retiring the declared key was a separate decision. #7359
252+
// took the enforce route, so that pin is superseded rather than deleted:
253+
// the cases below assert the opposite behaviour on the same input.
254+
255+
it('FORWARDS a declared `?status=` to the service instead of dropping it', async () => {
256+
// The defect: `status` is declared by `ListRunsRequestSchema` but was
257+
// never built into this handler's option object, so it never left the
258+
// HTTP layer. The caller got 200 + every run of the flow — a caller
259+
// paging for failures read the first `limit` runs of ANY status and
260+
// concluded those were the failures.
188261
const { result, listRuns } = await listWith({ limit: '2', status: 'failed' });
189262

190263
expect(result.response?.status).toBe(200);
191-
expect(listRuns).toHaveBeenCalledWith('welcome_flow', { limit: 2, cursor: undefined });
264+
expect(listRuns).toHaveBeenCalledWith('welcome_flow', { limit: 2, cursor: undefined, status: 'failed' });
265+
});
266+
267+
it.each(
268+
['pending', 'running', 'paused', 'completed', 'failed', 'cancelled', 'timed_out', 'retrying'],
269+
)('forwards every declared ExecutionStatus member — ?status=%s', async (member) => {
270+
// The gate reads its members from the spec's `ExecutionStatus` enum, the
271+
// same one `ListRunsRequestSchema` is built from, so this pins that the
272+
// wire's declared set and the boundary's accepted set are one set. A
273+
// member added to the enum and refused here would fail this row.
274+
const { result, listRuns } = await listWith({ status: member });
275+
276+
expect(result.response?.status).toBe(200);
277+
expect(listRuns).toHaveBeenCalledWith('welcome_flow', { limit: undefined, cursor: undefined, status: member });
278+
});
279+
280+
it.each([
281+
['absent', {}],
282+
['?status= (empty — an "All statuses" select)', { status: '' }],
283+
['status: null', { status: null }],
284+
])('%s still means NO filter — the unnarrowed listing is unchanged', async (_label, query) => {
285+
// The preservation half. An absent filter must keep reaching the
286+
// service as `undefined` (list everything), and the empty spelling must
287+
// not become a new 400: unlike `?read=`, which used to serve the wrong
288+
// HALF of the inbox, `?status=` already served exactly what "no filter"
289+
// means, so it had a defensible answer to preserve.
290+
const { result, listRuns } = await listWith(query);
291+
292+
expect(result.response?.status).toBe(200);
293+
expect(listRuns).toHaveBeenCalledWith('welcome_flow', expect.objectContaining({ status: undefined }));
192294
});
193295

194296
it('still refuses an anonymous caller with 401 before it ever looks at the query', async () => {

0 commit comments

Comments
 (0)