11// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
33/**
4- * #7300 / #7359 — `GET /api/v1/automation/:name/runs`'s query parameters, at
5- * the boundary that reads them.
4+ * #7300 / #7359 / #8054 — `GET /api/v1/automation/:name/runs`'s query
5+ * parameters, at the boundary that reads them.
66 *
77 * #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
8+ * COERCED. #7359 closed a third shape: `status` was declared by
109 * `ListRunsRequestSchema`, had no slot on `IAutomationService.listRuns`, and
1110 * was never built into the handler's option object — so `?status=failed` was
1211 * dropped here in silence and the caller was answered with EVERY run of the
1312 * 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.
13+ * decide it; #7359 took the enforce route, so that one pin was superseded by
14+ * cases asserting the opposite on the same input. #8054 is the sibling of
15+ * #7359 on the SAME route's OTHER declared constraint: `limit` was already
16+ * type-checked (#7300) but its declared RANGE (`.min(1).max(100)`) was never
17+ * read, so `?limit=0` answered 200 with zero rows — "this flow has never
18+ * run", confidently, about a flow with runs — and `?limit=101` reached the
19+ * engine with its cap simply not applied. The `?limit=1000`/`?limit=-5`/
20+ * `?limit=0` preservation rows #7300 pinned are superseded here the same way
21+ * #7359 superseded the `status`-ignored case: same input, opposite behaviour.
1622 *
1723 * The filed defect is character-for-character #6928's, one file over:
1824 * `{ limit: query.limit ? Number(query.limit) : undefined, cursor: query.cursor }`.
3541 * absence of a throw, which is not the defect. The defect is the missing
3642 * envelope.
3743 * 2. PRESERVATION — every value that had a defensible answer before keeps it,
38- * byte for byte, at the exact `listRuns(name, options)` call. That includes
39- * out-of-RANGE numbers (`?limit=1000`), which `ListRunsRequestSchema` bounds
40- * and the engine slices by: range is the service's declared business and
41- * stays reachable, unrefused.
44+ * byte for byte, at the exact `listRuns(name, options)` call. As of #8054
45+ * that no longer includes out-of-RANGE numbers (`?limit=1000`, `?limit=0`):
46+ * `ListRunsRequestSchema` bounds `limit` to 1..100 and the boundary now
47+ * enforces that declared range instead of only the value's type, so those
48+ * inputs moved from PRESERVATION to REFUSAL. An ORDINARY in-range value
49+ * (`?limit=25`) and both declared boundary values (`?limit=1`,
50+ * `?limit=100`) still keep their defensible answer — the over-block guard
51+ * for the new range check.
4252 *
4353 * The wire mapping of the thrown shape to `400` + `details.fields[]` is not
4454 * re-proved here — it is one mapping for every domain handler, pinned at both
@@ -197,6 +207,42 @@ describe('#7359 — a `?status=` outside the declared set is refused, not silent
197207 } ) ;
198208} ) ;
199209
210+ describe ( '#8054 — a `?limit=` outside the declared 1..100 range is refused, not silently answered' , ( ) => {
211+ // Measured, twice, identical both passes: `?limit=0` answered 200 with
212+ // ZERO rows (a confidently wrong "this flow has never run" — the store
213+ // sliced `.slice(0, 0)`), and `?limit=101` answered 200 with the cap
214+ // simply not applied. `ListRunsRequestSchema` had declared `.min(1).max(100)`
215+ // the whole time; this boundary just never read it. Once the range is
216+ // enforced there is no safe reading for a value outside it — same
217+ // reasoning #7359 already applied to `status`, on a bounded number instead
218+ // of a closed set.
219+ it . each ( [
220+ [ '0 (the "no runs" trap)' , '0' , 'min_value' ] ,
221+ [ '-5 (negative)' , '-5' , 'min_value' ] ,
222+ [ '101 (one past the declared cap)' , '101' , 'max_value' ] ,
223+ [ '1000 (far past the declared cap — the old preserved case, inverted)' , '1000' , 'max_value' ] ,
224+ ] ) ( 'refuses ?limit=%s with 400 VALIDATION_FAILED (%s)' , async ( _label , raw , expectedCode ) => {
225+ const { details, status, listRuns } = await refusalFor ( { limit : raw } ) ;
226+
227+ // ADR-0112: the envelope, not merely the throw — `code` AND `status`.
228+ expect ( details ?. code ) . toBe ( 'VALIDATION_FAILED' ) ;
229+ expect ( status ) . toBe ( 400 ) ;
230+ // ADR-0114: `min_value`/`max_value` are the field codes the property
231+ // names already mirror — no new vocabulary minted for this.
232+ expect ( details ?. fields ) . toEqual ( [
233+ { field : 'limit' , code : expectedCode , message : expect . stringContaining ( '`limit`' ) } ,
234+ ] ) ;
235+ // The whole point: the service is never reached with a limit outside
236+ // its own declared contract, so no caller reads a wrong-but-confident
237+ // "no runs" and no caller gets an uncapped result set.
238+ expect ( listRuns ) . not . toHaveBeenCalled ( ) ;
239+ } ) ;
240+
241+ // The boundary values themselves — `?limit=1` and `?limit=100` — are
242+ // pinned as VALID in the `#7300` preservation block below (they were
243+ // always in range and stay unaffected), so they are not repeated here.
244+ } ) ;
245+
200246describe ( '#7300 — every value that had a defensible answer keeps it' , ( ) => {
201247 async function listWith ( query : Record < string , unknown > | undefined ) {
202248 const { dispatcher, listRuns } = makeDispatcher ( ) ;
@@ -207,19 +253,26 @@ describe('#7300 — every value that had a defensible answer keeps it', () => {
207253 it . each ( [
208254 // [label, query, the exact options object `listRuns` must receive]
209255 [ '?limit=20' , { limit : '20' } , { limit : 20 , cursor : undefined , status : undefined } ] ,
256+ // An ordinary in-range value is the over-block guard for #8054: bounds
257+ // threading must not start refusing numbers that were always fine.
258+ [ '?limit=25 (ordinary, mid-range)' , { limit : '25' } , { limit : 25 , cursor : undefined , status : undefined } ] ,
210259 [ '?limit=1 (the low boundary)' , { limit : '1' } , { limit : 1 , cursor : undefined , status : undefined } ] ,
211260 [ '?limit=100 (the declared high boundary)' , { limit : '100' } , { limit : 100 , cursor : undefined , status : undefined } ] ,
212- // Out of RANGE is not out of DOMAIN. `ListRunsRequestSchema` bounds
213- // `limit` to 1..100 and the engine slices by whatever it is handed;
214- // neither answer is this boundary's to change, so both still arrive.
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 } ] ,
217- // Falsy spellings meant "no limit here" before this gate existed and
218- // still do — they must not become a new 400. `'0'` is NOT one of them:
219- // the string is truthy, so `query.limit ? Number(query.limit) : …` read
220- // it as the number `0` and passed it on, and that is preserved too.
261+ // Out-of-RANGE numbers used to be preserved here (`?limit=1000`,
262+ // `?limit=-5`, `?limit=0`) on the theory that range was the engine's
263+ // declared business, not this boundary's. #8054 found the one place
264+ // that reasoning was wrong: `ListRunsRequestSchema` had ALWAYS
265+ // declared `limit`'s range, and nothing enforced it, so `?limit=0`
266+ // answered "no runs" and `?limit=101` reached the engine uncapped.
267+ // Those three rows are superseded by the `#8054` refusal block below
268+ // rather than deleted outright — same input, opposite behaviour now.
269+ //
270+ // Falsy spellings still mean "no limit here", unaffected by bounds
271+ // because the falsy gate runs BEFORE the bounds check: absent, `null`,
272+ // `''`, and an in-process (non-string) `0` never reach it. `'0'` as a
273+ // QUERY-STRING value is different — the string is truthy, so it always
274+ // reached `Number()` — and is exercised in the `#8054` block instead.
221275 [ '?limit= (empty)' , { limit : '' } , { limit : undefined , cursor : undefined , status : undefined } ] ,
222- [ '?limit=0' , { limit : '0' } , { limit : 0 , cursor : undefined , status : undefined } ] ,
223276 [ 'limit: 0 (in-process number)' , { limit : 0 } , { limit : undefined , cursor : undefined , status : undefined } ] ,
224277 [ 'limit: null' , { limit : null } , { limit : undefined , cursor : undefined , status : undefined } ] ,
225278 [ 'no parameters at all' , { } , { limit : undefined , cursor : undefined , status : undefined } ] ,
0 commit comments