Skip to content

Commit ae87f1f

Browse files
committed
docs(client): narrow the limit-refusal sentence to what the guard does; re-point the card
FAIL ground: 'a value outside that range is REFUSED' was false at exactly the falsy inputs. automation.runs.list guards on truthiness, so { limit: 0 } and NaN are dropped client-side and the server answers its default window; the two listRuns surfaces guard on != null and do send them. Proven by executing all three emitters against eight edge inputs, not by reading. Option (b): the guard is pre-existing and unauthorized to change, so the sentence narrows to what the code does rather than the code changing to fit the sentence. Same stroke: 'read by nothing on the server' becomes 'validated at the boundary and read by nothing beyond it', matching the prescription — the boundary did read the key, to validate it. Card rebuild: #19365 is permanently 404 and #19543 replaces it. All 41 citations in this diff re-pointed (39 hand-written, 2 regenerated into the registry mirror). #6361 is untouched at 13 occurrences — still 404, not among the gate's sites, and not reconstructable. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99ad620 commit ae87f1f

16 files changed

Lines changed: 57 additions & 48 deletions

‎.changeset/19365-automation-runs-cursor-hasmore.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
'@objectstack/client': minor
66
---
77

8-
feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19365)
8+
feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19543)
99

1010
This door declared a pagination parameter it never spent and then reported, as a
1111
literal, that there was nothing more to fetch. Both halves are closed here, per

‎content/docs/automation/flows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1842,7 +1842,7 @@ curl -b cookies.txt -X POST \
18421842
| Endpoint | Purpose |
18431843
|:---|:---|
18441844
| `POST /api/v1/automation/:name/trigger` | Start a flow (canonical) |
1845-
| `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** (#19365): 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) |
1845+
| `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) |
18461846
| `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) |
18471847
| `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` |
18481848
| `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run |

‎packages/client/src/client.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,21 +1433,21 @@ describe('ObjectStackClient.automation', () => {
14331433

14341434
// `limit` is the whole query surface of this door now. It used to be
14351435
// pinned here alongside `cursor=abc`; that half moved to the absence
1436-
// pin below when #19365 retired the key.
1436+
// pin below when #19543 retired the key.
14371437
await client.automation.runs.list('my_flow', { limit: 5 });
14381438
expect(fetchMock).toHaveBeenCalledWith(
14391439
'http://localhost:3000/api/v1/automation/my_flow/runs?limit=5',
14401440
expect.any(Object),
14411441
);
14421442
});
14431443

1444-
it('[#19365] never puts a `cursor` on the query string — on ANY of the three run-list surfaces', async () => {
1444+
it('[#19543] never puts a `cursor` on the query string — on ANY of the three run-list surfaces', async () => {
14451445
// This test used to assert the OPPOSITE — it pinned the URL
14461446
// `…/runs?limit=5&cursor=abc`, i.e. that the SDK produced the key. That
14471447
// is what made the parameter harmful rather than inert: `cursor` was
14481448
// accepted at the boundary and read by nothing, so a caller paginating
14491449
// by the published contract re-read the first window forever with no
1450-
// error. #19365 retires it, and the assertion inverts on the same input.
1450+
// error. #19543 retires it, and the assertion inverts on the same input.
14511451
//
14521452
// The type surface is the enforced channel — `list({ cursor })` is a
14531453
// TS2353 excess-property error, which a runtime assertion cannot reach.

‎packages/client/src/index.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5538,15 +5538,24 @@ export class ObjectStackClient {
55385538
*
55395539
* Returns the newest `limit` runs — a WINDOW, not a page. The
55405540
* `cursor` parameter was removed in `@objectstack/spec` 17.5.0
5541-
* (#19365): it was appended to the query string here and read by
5542-
* nothing on the server, so a caller paginating by it re-read the
5543-
* first window forever.
5541+
* (#19543): it was appended to the query string here, validated at
5542+
* the boundary and read by nothing beyond it, so a caller
5543+
* paginating by it re-read the first window forever.
55445544
*
5545-
* Omit `limit` to take the server's window (20). It is
5546-
* bounded to 1..100 and a value outside that range is REFUSED with
5547-
* `400 VALIDATION_FAILED`, never clamped — so raise it deliberately
5548-
* to see further back. There is no continuation token — read
5549-
* `hasMore` to learn whether the window was short.
5545+
* Omit `limit` to take the server's window (20). The declared range
5546+
* is 1..100, and a value this method SENDS that falls outside it is
5547+
* REFUSED with `400 VALIDATION_FAILED`, never clamped — so raise it
5548+
* deliberately to see further back.
5549+
*
5550+
* ⚠️ `0` and `NaN` are the exception, and they are dropped rather
5551+
* than refused: the guard below is truthy, so a falsy `limit` never
5552+
* leaves the client and the server answers its DEFAULT window
5553+
* instead. `-5`, `1.5` and `101` are truthy, are sent, and are
5554+
* refused. The two `listRuns` surfaces guard on `!= null` and do
5555+
* send `0`.
5556+
*
5557+
* There is no continuation token — read `hasMore` to learn whether
5558+
* the window was short.
55505559
*/
55515560
list: async (flowName: string, options?: { limit?: number }): Promise<{ runs: ExecutionLog[]; hasMore: boolean }> => {
55525561
const route = this.getRoute('automation');
@@ -5620,7 +5629,7 @@ export class ObjectStackClient {
56205629
/**
56215630
* Alias for `automation.runs.list`.
56225631
*
5623-
* `cursor` was removed in `@objectstack/spec` 17.5.0 (#19365) — see that
5632+
* `cursor` was removed in `@objectstack/spec` 17.5.0 (#19543) — see that
56245633
* method for the reason. A window, not a page: widen `limit`
56255634
* (1..100, default 20) and read `hasMore`.
56265635
*/
@@ -8105,7 +8114,7 @@ export class ScopedEnvironmentClient {
81058114
/**
81068115
* List recent runs for a flow, optionally narrowed to one status.
81078116
*
8108-
* `cursor` was removed in `@objectstack/spec` 17.5.0 (#19365) — see
8117+
* `cursor` was removed in `@objectstack/spec` 17.5.0 (#19543) — see
81098118
* `automation.runs.list` for the reason. A window, not a page: widen
81108119
* `limit` (1..100, default 20) and read `hasMore`.
81118120
*/

‎packages/runtime/src/domains/automation-run-read-permission-gate.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ function makeDispatcher(
9494
): Harness {
9595
const explainCalls: ExplainCall[] = [];
9696
const getRun = vi.fn(async () => PAUSED_RUN as unknown);
97-
// [#19365] The door reads the PAGE member — `listRuns` alone cannot
97+
// [#19543] The door reads the PAGE member — `listRuns` alone cannot
9898
// report truncation, so a door that has to answer `hasMore` calls this
9999
// one. The gate under test is unaffected either way: it refuses ahead of
100100
// the service probe, deliberately, so that a 501-vs-403 is not what tells

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import { validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validatio
6868
* An automation slot whose `listRunsPage` records exactly what it was asked
6969
* for.
7070
*
71-
* [#19365] The double serves `listRunsPage` — the page-shaped member the door
71+
* [#19543] The double serves `listRunsPage` — the page-shaped member the door
7272
* now calls — rather than `listRuns`. The recorded OPTIONS object is what
7373
* every preservation row below pins, and it is unchanged by that switch except
7474
* for the retired `cursor` key: the door still forwards the caller's own
@@ -157,7 +157,7 @@ describe('#7300 — GET /automation/:name/runs refuses a malformed `limit` inste
157157
});
158158
});
159159

160-
describe('#19365 — `cursor` is RETIRED, so this boundary stops reading it', () => {
160+
describe('#19543 — `cursor` is RETIRED, so this boundary stops reading it', () => {
161161
// ⚠️ This block SUPERSEDES #7300's cursor refusal cases rather than
162162
// extending them, and the supersession is a deliberate reversal, not a
163163
// relaxation that slipped through. #7300 refused `?cursor=a&cursor=b` with
@@ -209,7 +209,7 @@ describe('#19365 — `cursor` is RETIRED, so this boundary stops reading it', ()
209209
});
210210
});
211211

212-
describe('#19365 — `hasMore` is RELAYED from the service, never a constant', () => {
212+
describe('#19543 — `hasMore` is RELAYED from the service, never a constant', () => {
213213
// The defect this closes, in the source's own words: the door returned
214214
// `deps.success({ runs, hasMore: false })` — a literal — beside a list the
215215
// engine had already cut with `.slice(0, limit)`. A caller asking for one
@@ -401,7 +401,7 @@ describe('#7300 — every value that had a defensible answer keeps it', () => {
401401
['no parameters at all', {}, { limit: undefined, status: undefined }],
402402
// The three `?cursor=` preservation rows that stood here — a verbatim
403403
// string, the empty spelling, and `limit` + `cursor` together — are
404-
// superseded by the `#19365` block above rather than deleted outright:
404+
// superseded by the `#19543` block above rather than deleted outright:
405405
// the key is retired, so "reaches the service unchanged" is no longer
406406
// the behaviour to preserve. What replaced them asserts the opposite
407407
// on the same inputs, which is the same supersession shape #7359 and

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,7 +1539,7 @@ async function consumedSuspensionSurvives(
15391539
* GET /:name/runs → listRunsPage (query: limit — validated AND
15401540
* honoured end to end, #7300 / #8054; status —
15411541
* validated AND honoured, #7359; cursor —
1542-
* RETIRED, #19365, so a value carrying it is
1542+
* RETIRED, #19543, so a value carrying it is
15431543
* ignored rather than validated). `hasMore` is
15441544
* computed from the engine's own truncation
15451545
* report, never a constant. A service without
@@ -2571,7 +2571,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
25712571
// first implementation that starts honouring cursors must not
25722572
// be the one that discovers the type was never enforced.
25732573
//
2574-
// [#19365] ⚠️ THAT SECOND BULLET IS NOW HISTORY, AND ITS
2574+
// [#19543] ⚠️ THAT SECOND BULLET IS NOW HISTORY, AND ITS
25752575
// DECISION IS REVERSED ON PURPOSE. #7300 chose to validate a
25762576
// key rather than decide it, on the reasoning that a future
25772577
// cursor implementation must not be the one to discover the
@@ -2662,7 +2662,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
26622662
const { runs, hasMore } = await automationService.listRunsPage(name, options);
26632663
return { handled: true, response: deps.success({ runs, hasMore }) };
26642664
}
2665-
// [#19365] A service that does not implement `listRunsPage` is told
2665+
// [#19543] A service that does not implement `listRunsPage` is told
26662666
// so, and ⛔ never answered 200 with an invented `hasMore`. Falling
26672667
// through to the domain's 404 would have been the silent form: the
26682668
// caller cannot tell "this deployment mounts no run listing" from

‎packages/runtime/src/http-dispatcher.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ describe('HttpDispatcher', () => {
297297
execute: vi.fn().mockResolvedValue({ success: true, output: {} }),
298298
toggleFlow: vi.fn().mockResolvedValue(undefined),
299299
listRuns: vi.fn().mockResolvedValue([{ id: 'run_1', status: 'completed' }]),
300-
// [#19365] The run-list door calls the PAGE member; `listRuns`
300+
// [#19543] The run-list door calls the PAGE member; `listRuns`
301301
// stays declared here because the CONTRACT still declares it,
302302
// and this mock's subject is contract completeness (#4127).
303303
listRunsPage: vi.fn().mockResolvedValue({

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4568,7 +4568,7 @@ export class AutomationEngine implements IAutomationService {
45684568
flowName: string,
45694569
options?: { limit?: number; status?: ExecutionStatus },
45704570
): Promise<ExecutionLogEntry[]> {
4571-
// [#19365] ONE implementation, two projections — `listRunsPage` is the
4571+
// [#19543] ONE implementation, two projections — `listRunsPage` is the
45724572
// whole method and this is its `runs` half. ⛔ Never re-derive the
45734573
// listing here: a second copy of the merge/filter/sort would be the
45744574
// fork the route-ownership rule refuses, and it is the half that would
@@ -4577,7 +4577,7 @@ export class AutomationEngine implements IAutomationService {
45774577
}
45784578

45794579
/**
4580-
* [#19365] The run listing AND whether it was truncated — the member the
4580+
* [#19543] The run listing AND whether it was truncated — the member the
45814581
* REST door builds `hasMore` from.
45824582
*
45834583
* ## What "truncated" means at this seam, and why `runs.length === limit` is not it
@@ -4701,7 +4701,7 @@ export class AutomationEngine implements IAutomationService {
47014701
let durable: ExecutionLogEntry[] = [];
47024702
if (this.store?.listHistory) {
47034703
try {
4704-
// [#19365] `limit + 1`, not `limit` — the over-read that makes
4704+
// [#19543] `limit + 1`, not `limit` — the over-read that makes
47054705
// `hasMore` answerable at all. Asking for exactly `limit` makes a
47064706
// saturated window and a complete one identical; one extra row
47074707
// tells them apart, and `.slice(0, limit)` below drops it again
@@ -4795,7 +4795,7 @@ export class AutomationEngine implements IAutomationService {
47954795
: [...byId.values()].filter(e => e.status === status);
47964796
const ordered = merged
47974797
.sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''));
4798-
// [#19365] The comparison is against the ORDERED, FILTERED set, not
4798+
// [#19543] The comparison is against the ORDERED, FILTERED set, not
47994799
// against what any single source returned: a row can reach `ordered`
48004800
// from the ring or the paused arm without the history arm knowing, and
48014801
// a `?status=` filter can drop the over-read row specifically. Reading

‎packages/services/service-automation/src/run-list-truncation.test.ts‎

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

33
/**
4-
* #19365 — `AutomationEngine.listRunsPage` and the truncation boundary.
4+
* #19543 — `AutomationEngine.listRunsPage` and the truncation boundary.
55
*
66
* `GET /api/automation/:name/runs` used to answer `{ runs, hasMore: false }`
77
* with the `false` written as a literal, beside a list the engine had already
@@ -83,7 +83,7 @@ async function pageOf(count: number, limit: number) {
8383
return engine.listRunsPage(FLOW, { limit });
8484
}
8585

86-
describe('#19365 — hasMore at the truncation boundary', () => {
86+
describe('#19543 — hasMore at the truncation boundary', () => {
8787
it.each([
8888
['far fewer than the window', 3, 10, false, 3],
8989
['one short of the window', 9, 10, false, 9],
@@ -117,7 +117,7 @@ describe('#19365 — hasMore at the truncation boundary', () => {
117117

118118
it('asks the STORE for `limit + 1` — the over-read is where the fact comes from', async () => {
119119
// The mechanism pin. `RunStore.listHistory`'s signature is deliberately
120-
// unchanged (#19365): over-reading is expressible in the `limit` it
120+
// unchanged (#19543): over-reading is expressible in the `limit` it
121121
// already takes, so the truncation signal costs the store contract
122122
// nothing. A regression to `listHistory(flow, limit)` would make the
123123
// EXACTLY-the-window case above indistinguishable from the one above
@@ -179,7 +179,7 @@ describe('#19365 — hasMore at the truncation boundary', () => {
179179
});
180180
});
181181

182-
describe('#19365 — `listRuns` is the `runs` half of the same call', () => {
182+
describe('#19543 — `listRuns` is the `runs` half of the same call', () => {
183183
it('returns the identical window, and reports no truncation of its own', async () => {
184184
// ONE implementation, two projections. A second merge/filter/sort here
185185
// would be the fork the route-ownership rule refuses, and it is the

0 commit comments

Comments
 (0)