Commit 0b4022b
Part of #19543
Clause-②: yes
Door ① of three. `GET /api/automation/:name/runs` declared a pagination
parameter it never spent, and then reported — as a literal — that there
was
nothing more to fetch. Both halves are addressed here.
## The ruling, which is the maintainer's call and not this PR's
Comment `5754491070` on #19365 records decision batch #204 item 2,
⚠️ and neither that comment nor that card resolves any more — #19365 was
removed from
the board on 2026-09-21 and GitHub cannot restore a number. The number
is kept here
rather than re-pointed, because the comment was never on any other card
and naming a
different one would be false. **The live record is #19543**, the
rebuild, which carries
this ruling quoted verbatim together with what could not be recovered.
The ruling's own
durable copy is in this diff: the `reason` field of the D3 entry in
`packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts`.
letters
`C · C · A` per door, maintainer 「204 同意」 2026-09-21. For door ① the
ruling reads, verbatim:
> `cursor` is retired from `ListRunsRequestSchema`; `limit` stays (it is
read
> end to end and the Console's flow-runs page sends it today); the
engine
> reports truncation to the route and **`hasMore` is computed**, never
> hard-coded. A (a cursor protocol for a 100-row window) and B (retire
cursor
> and leave the lie) are ⛔ not taken.
⛔ Not re-adjudicated here. Letter A — building a cursor protocol — is
explicitly not taken, so no continuation token is minted and
`nextCursor` stays
absent.
**Why `Part of` and not a closing keyword.** Doors ② (export jobs) and ③
(AI
conversations) are ruled but gated on a cloud-repo reading riding #19545
(the rebuild of #19361, which no longer resolves), and
the ruling has the seat execute them on that reading's return without
re-entering the decision box. A merge that shut the card would strand
two-thirds of the ruled work, so the card stays open and the seat
re-labels it.
The gate `scripts/check-partof-closing-keyword.mjs` is the mechanical
half of
that, and its RULE 3 is why no sentence here binds a closing keyword to
a
number at all — not even one written to prevent an auto-close, which is
the
exact incident that gate exists for.
## The premise was re-measured, and one half of the card's body is false
Every reading below was re-taken on `origin/main` at `5e7d83c`, not
relayed.
| claim | reading |
|---|---|
| `cursor` declared, never read | **holds** — `ListRunsRequestSchema`
declared it; `AutomationEngine.listRuns` never looked at the option; no
emit site writes `nextCursor` |
| `hasMore` hard-coded | **holds** — `automation.ts` returned
`deps.success({ runs, hasMore: false })`, a literal, beside
`merged.slice(0, limit)` |
| `limit` declared, never read | ⛔ **FALSE** — read end to end |
| `.default(20)` unique to the export door | ⛔ **FALSE** —
`ListRunsRequestSchema` carries it too |
`limit` is read at the boundary (`parseIntegerParam`, with the `1..100`
bounds
taken off the schema itself), forwarded to `IAutomationService`, and
spent by
the engine as `RunStore.listHistory`'s window. It is also pinned by live
enforcement in `automation-runs-query-validation.test.ts`. **Retiring it
would
have been a regression, not a narrowing**, and the ruling says the
`/packages`
parent ruling `5651023067` does not transfer. Both corrections belong on
the
card's thread, which is the census.
## What "truncated" means at this seam
The tempting signal is `runs.length === limit`. It is wrong at exactly
one
input, and that input is undetectable 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 as wrong as
`false` for
the second.
Only one of the three sources `listRuns` merges was ever capped — the
durable
history arm, because `RunStore.listHistory(flowName, limit)` takes the
window
as an argument. The paused arm and the in-memory ring are read in full.
So the
signal chosen 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`. Overflow means a run matched that this window does not carry.
The
extra row is dropped by the same `.slice(0, limit)` that was always
there, so
nothing on the wire widens.
⛔ `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.
Two things `hasMore` deliberately does not mean, both pinned:
- ⛔ **not** "retention evicted older runs" — a run the per-flow cap
discarded
does not exist any more; it is not "more" and no `limit` brings it back.
- ⛔ **not** "there is a next page" — nothing mints a cursor. The
caller's
remedy is a wider `limit`, up to the declared 100.
**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. A status-filtered `hasMore: false` therefore means "no
further
match within the scanned window", not "no further match exists". Pushing
the
filter down is a store-contract change; the engine's own comment already
recorded this for the listing itself, and it is called out in the new
test's
docblock rather than papered over.
## Behaviour changes on the wire
**1. `?cursor=a&cursor=b` answered `400 VALIDATION_FAILED`; it now
answers
`200` with the key ignored.** This reverses a decision recorded under
#7300,
which chose to validate the key rather than decide it — the reasoning
being
that a future cursor implementation must not be the one to discover the
type
was never enforced. The ruling decides it instead: there will be no
cursor
implementation on this door, so a 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. The
old
refusal cases are superseded by cases asserting the opposite on the same
inputs — the shape #7359 and #8054 already used on this route's other
two
parameters.
**2. `hasMore` can now be `true`.** A request whose window is shorter
than the
matching run set receives `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.
**3. A service implementing no `listRunsPage` answers `501`** naming the
member, never a `200` carrying a guessed `hasMore`. "Absence must be
loud" —
falling through to the domain's `404` would leave a caller unable to
tell "no
run listing is mounted here" from "no such flow". The `403` run-read
grant runs
ahead of the service probe and is unaffected, which is what that gate's
own
note already required.
## Shape of the change
- **spec** — `cursor: retiredKey(RUNS_LIST_CURSOR_REMOVED)`. A
tombstone, not a
deletion: the request schema 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 defect re-created one
layer down (ADR-0104). The form is copied from the landed sibling
(#17667 / PR #19364 — that PR number no longer resolves and has no
rebuild, being a merged PR rather than a card; card #17667 resolves and
is the live record) rather than invented.
- **contract** — new optional `IAutomationService.listRunsPage`
returning the
exported `RunListResult` (`{ runs, hasMore }`) — the shape
`IExportService.listExportJobs` already uses, minus the cursor nothing
mints.
`cursor` leaves `listRuns`'s options in the same stroke.
- **engine** — `listRunsPage` holds the whole method; `listRuns` is its
`runs`
half. ⭐ One implementation, two projections, so there is no second
merge/filter/sort to rot. This is also why ~120 existing `listRuns` call
sites across `service-automation`, `plugin-approvals`, `examples/` and
`packages/cli` are untouched.
- **ADR-0087** — `RETIRED_KEYS_BY_MAJOR[18]` entry plus the D3 semantic
entry
`automation-runs-cursor-retired`. No D2 conversion: a conversion
rewrites an
authored source or a stored `sys_metadata` row, and this shape is
HTTP-only.
Registered at 18, not 17, per the sibling convention.
- **changeset** — `minor` across the three published packages, carrying
the
ADR-0087 disposition `registered automation-runs-cursor-retired`.
- **docs** — `content/docs/automation/flows.mdx`'s REST route table
advertised `?cursor` on this
route. That row is false once the key is retired, so it now states the
retirement, that a
request still carrying the key is **ignored rather than refused**, and
that `hasMore` is
computed with a wider `?limit` as the remedy. Flagged by Docs Drift
Check (`5755158989`); the
other 10 pages it named document the DATA door's `hasMore` and are true
as they stand, so none
was edited. Written by the dispatching seat, not the implementer — the
implementer's one body
write was spent at create.
- **SDK** — `@objectstack/client` declared `cursor` and appended
`?cursor=` on all three run-list
surfaces (`automation.runs.list`, `automation.listRuns`,
`client.environment(id).automation.listRuns`).
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
ADR-0104 silent strip the tombstone
exists to prevent, one layer down. The option and the emitter are gone
from all three, the URL pin is
inverted into a three-surface absence pin, and `'@objectstack/client':
minor` joins the changeset. Same
call the repo made when #6361 retired the notifications `cursor`. Added
by the dispatching seat after the
at-tier contract review FAILed the previous head on exactly this; the
implementer's one body write was
spent at create.
## Verification
- `automation-runs-query-validation.test.ts`: 48 → **51**, and every
assertion
that moved is named. Removed: the `#7300` cursor-refusal describe (3
parametrised cases) and 3 `?cursor=` preservation rows — superseded, not
deleted, with the replacement asserting the opposite on the same inputs.
Added: 6 retirement cases and 3 `hasMore`-relay cases. Changed: the
double
now serves `listRunsPage`, and `cursor: undefined` left 10 expected
options
objects. **The `limit` preservation rows are byte-identical otherwise**
—
the door still forwards the caller's own window, never a widened one,
because the over-read lives in the engine.
- New `run-list-truncation.test.ts` (14 cases) pins the boundary table —
fewer than / **exactly** / more than `limit` — plus a spy proving the
store
is asked for `limit + 1`.
- `pnpm test`: runtime 271 files, service-automation 141 files / 1690
tests.
- `pnpm typecheck`: spec, runtime, service-automation — all green, no
new
`test-typecheck-debt.json` entries.
- Derived gate union (`scripts/pm/dispatch-gates.mjs --commands`,
reconciled
with `--ran`): **112 derived · 110 exit 0 · 2 exit 3 (NOT MEASURED) · 0
unrun**. Exit codes were captured before any pipe. The two are
environmental
refusals, ⛔ not findings and ⛔ not passes:
`check-plugin-teardown-shape --self-test` cannot reach a commit-pinned
positive control in a shallow checkout (`--is-shallow-repository` is
`true`
here; **the gate itself ran, exit 0**), and `check:dual-build-cjs-loads`
refuses without a repo-wide build (38 packages carry no `dist/`). CI has
both. Two further families initially refused on the same prerequisite
class
and were converted into real readings by building what they read:
`check:skill-examples` (258 prose examples type-check) and
`check:type-check-debt` (4 ledger entries re-measured, 53 raw errors,
none
above its recorded number).
## Serial constraints
Declared adjacency from the dispatch: PR #19373 holds
`packages/spec/dropped-refinements.baseline.json`,
`packages/spec/api-surface/root.json` and
`packages/spec/export-origins/root.json`. **This PR moves none of those
three**
— regeneration landed on the `contracts` shards
(`api-surface/contracts.json`, `export-origins/contracts.json`) plus
`authorable-surface/api.json`, all disjoint. `origin/main` was merged
before
this reading and `check:generated` reports all 15 artefacts current.
## Acceptance notes
Out of scope, observed, ⛔ not filed and ⛔ not widened into this PR:
- **`ListRunsResponseSchema.nextCursor` stays declared and never
emitted.**
Not a contract violation — an absent optional key promises nothing — so
it
is not class (b), and minting one is letter A, explicitly not taken. Now
commented in place. Whoever takes door ② or ③ touches the same file.
- **`GET /automation` (list flows) also ships a literal `hasMore:
false`.**
Measured, and there it is *true*: the handler returns every name with
`total === names.length`, so nothing is withheld. Recorded so the next
reader does not read the two literals as the same defect. No card.
- **The `?status=` window residual** described above is a real narrowing
of
what `hasMore: false` can promise. It is pre-existing, it is the
engine's own
recorded limitation, and closing it is a `RunStore` contract change —
the
ruling scoped this card to the truncation signal.
Deviations from the dispatch's declared file surface, both required by
the
ruling's own text and reported rather than taken silently:
`packages/spec/src/contracts/automation-service.ts` (the ruling's
"engine
reports truncation to the route" needs the contract member the route
calls),
and two `packages/runtime` test doubles that stub the run-list service —
`http-dispatcher.test.ts` and
`automation-run-read-permission-gate.test.ts`.
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc9e29b commit 0b4022b
20 files changed
Lines changed: 1225 additions & 93 deletions
File tree
- .changeset
- content/docs
- automation
- references/api
- packages
- client/src
- runtime/src
- domains
- services/service-automation/src
- spec
- api-surface
- authorable-surface
- export-origins
- src
- api
- contracts
- migrations
- entries
- retired-keys
- semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1842 | 1842 | | |
1843 | 1843 | | |
1844 | 1844 | | |
1845 | | - | |
| 1845 | + | |
1846 | 1846 | | |
1847 | 1847 | | |
1848 | 1848 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
524 | 524 | | |
525 | 525 | | |
526 | 526 | | |
527 | | - | |
| 527 | + | |
528 | 528 | | |
529 | 529 | | |
530 | 530 | | |
| |||
570 | 570 | | |
571 | 571 | | |
572 | 572 | | |
573 | | - | |
| 573 | + | |
574 | 574 | | |
575 | 575 | | |
576 | 576 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1425 | 1425 | | |
1426 | 1426 | | |
1427 | 1427 | | |
1428 | | - | |
| 1428 | + | |
1429 | 1429 | | |
1430 | 1430 | | |
1431 | 1431 | | |
1432 | 1432 | | |
1433 | 1433 | | |
1434 | | - | |
| 1434 | + | |
| 1435 | + | |
| 1436 | + | |
| 1437 | + | |
1435 | 1438 | | |
1436 | | - | |
| 1439 | + | |
1437 | 1440 | | |
1438 | 1441 | | |
1439 | 1442 | | |
1440 | 1443 | | |
| 1444 | + | |
| 1445 | + | |
| 1446 | + | |
| 1447 | + | |
| 1448 | + | |
| 1449 | + | |
| 1450 | + | |
| 1451 | + | |
| 1452 | + | |
| 1453 | + | |
| 1454 | + | |
| 1455 | + | |
| 1456 | + | |
| 1457 | + | |
| 1458 | + | |
| 1459 | + | |
| 1460 | + | |
| 1461 | + | |
| 1462 | + | |
| 1463 | + | |
| 1464 | + | |
| 1465 | + | |
| 1466 | + | |
| 1467 | + | |
| 1468 | + | |
| 1469 | + | |
| 1470 | + | |
| 1471 | + | |
| 1472 | + | |
| 1473 | + | |
| 1474 | + | |
| 1475 | + | |
| 1476 | + | |
| 1477 | + | |
| 1478 | + | |
| 1479 | + | |
| 1480 | + | |
| 1481 | + | |
| 1482 | + | |
| 1483 | + | |
1441 | 1484 | | |
1442 | 1485 | | |
1443 | 1486 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5534 | 5534 | | |
5535 | 5535 | | |
5536 | 5536 | | |
5537 | | - | |
| 5537 | + | |
| 5538 | + | |
| 5539 | + | |
| 5540 | + | |
| 5541 | + | |
| 5542 | + | |
| 5543 | + | |
| 5544 | + | |
| 5545 | + | |
| 5546 | + | |
| 5547 | + | |
| 5548 | + | |
| 5549 | + | |
| 5550 | + | |
| 5551 | + | |
| 5552 | + | |
| 5553 | + | |
| 5554 | + | |
| 5555 | + | |
| 5556 | + | |
| 5557 | + | |
| 5558 | + | |
5538 | 5559 | | |
5539 | | - | |
| 5560 | + | |
5540 | 5561 | | |
5541 | 5562 | | |
5542 | 5563 | | |
5543 | | - | |
5544 | 5564 | | |
5545 | 5565 | | |
5546 | 5566 | | |
| |||
5606 | 5626 | | |
5607 | 5627 | | |
5608 | 5628 | | |
5609 | | - | |
| 5629 | + | |
| 5630 | + | |
| 5631 | + | |
| 5632 | + | |
| 5633 | + | |
| 5634 | + | |
| 5635 | + | |
5610 | 5636 | | |
5611 | 5637 | | |
5612 | | - | |
| 5638 | + | |
5613 | 5639 | | |
5614 | 5640 | | |
5615 | 5641 | | |
5616 | 5642 | | |
5617 | | - | |
5618 | 5643 | | |
5619 | 5644 | | |
5620 | 5645 | | |
| |||
8086 | 8111 | | |
8087 | 8112 | | |
8088 | 8113 | | |
8089 | | - | |
| 8114 | + | |
| 8115 | + | |
| 8116 | + | |
| 8117 | + | |
| 8118 | + | |
| 8119 | + | |
| 8120 | + | |
8090 | 8121 | | |
8091 | 8122 | | |
8092 | | - | |
| 8123 | + | |
8093 | 8124 | | |
8094 | 8125 | | |
8095 | 8126 | | |
8096 | | - | |
8097 | 8127 | | |
8098 | 8128 | | |
8099 | 8129 | | |
| |||
0 commit comments