feat(automation): GET /automation/:name/runs retires cursor and computes hasMore - #19493
Conversation
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…tomation-runs-hasmore
…ls are readable Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 25d7dbd776aca23229f4dfaa60e8646f20a31108 && git checkout 25d7dbd776aca23229f4dfaa60e8646f20a31108
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ecf56e791e37bf1f5cc187c6824b003de704f528 ae87f1fde8e6748bdb29666c07171537a8f352c4 && git checkout -B drift-repro ecf56e791e37bf1f5cc187c6824b003de704f528 && git merge --no-ff ae87f1fde8e6748bdb29666c07171537a8f352c4
node scripts/docs-audit/affected-docs.mjs --json ecf56e791e37bf1f5cc187c6824b003de704f528
|
…d cursor Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
|
| lane | conclusion |
|---|---|
Type Check · source gates |
success |
Type Check · consumer gates |
cancelled |
Type Check · workspace |
cancelled |
Type Check · debt ledger |
cancelled |
The aggregator refused to report a pass over three lanes that were never measured. That is the gate being correct — cancelled is NOT MEASURED, and NOT MEASURED is ⛔ never a pass. It is also ⛔ never a red about the code.
Why they were cancelled. The branch head moved to 6506b7c6 and the PR object updated at 2026-09-21T03:57:25Z — the author's own next push, which cancels in-flight runs on the previous head by the workflows' concurrency group. ⇒ the failure belongs to a head that is no longer the tip.
The authoritative reading is the current head. 6506b7c6: 32 check names, 0 failures, 20 still running (latest run per name; superseded runs of the same name are not the reading).
⛔ Nothing was pushed for this and ⛔ no re-run was spent: there is no live failure to fix, and re-running a superseded head buys nothing. If TypeScript Type Check goes red on 6506b7c6 with its member lanes reading failure rather than cancelled, that is a real reading and this seat will root-cause it.
failure or cancelled. ⛔ Never judge this family by the red badge alone.
Reading taken 2026-09-21T03:57Z.
Generated by Claude Code
Contract reviewServed-tier: 104/104
① Derived judgmentsTruncation signal (the sharpest question). When Accept set and published surface. New optional What breaks the contract story. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL — What must change for a re-review to pass: retire Generated by Claude Code |
…lers actually reach The schema tombstone alone left @objectstack/client typing the key `string` and appending it into a route that no longer reads it — the ADR-0104 silent strip the tombstone exists to prevent, re-created one layer down. Drops the option and the `params.set` from all three run-list surfaces, inverts the URL pin, and qualifies the published hasMore docblocks under a status filter. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…tomation-runs-hasmore
The changeset and the D3 acceptance criteria both promised 'hasMore: true when the window is shorter than the matching set' without saying that the window is taken before the status filter is applied. Both ship to consumers — one as CHANGELOG.md, one into the major-18 upgrade guide — so both now carry the qualification the published docblocks already do. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 138/138
① Derived judgmentsThe prior FAIL ground is CLOSED, measured first-hand. Boundary — HELD. ② (a) ② (b) The rewritten ordering-key sentence is NOT exactly true — this is the verdict's sole ground. "the only ordering this door has is an optional, non-unique ③ Settled ground — re-measured, undisturbed. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL — What must change for a re-review to pass: make the ordering-key sentence exactly true by replacing "an optional, non-unique Generated by Claude Code |
The sentence that replaced an arguable claim introduced a false one. `startedAt` is required on every layer the sort touches — ExecutionLogEntry (engine.ts:1036) and ExecutionLogSchema (execution.zod.ts:399) both declare it without `?` or .optional(). The word came from the comparator's defensive `?? ''`, which is not evidence of an optional type. Corrected in the published prescription, the changeset, the retired-key entry and its registry mirror, and the reference row regenerated from it. Also repairs a splice artefact: inserting the SDK paragraph severed `ADR-0049 / ADR-0087, #19365.`, orphaning `ADR-0049 / ` mid-field. The pair is restored in the D3 reason and its registry mirror. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…tomation-runs-hasmore
Contract reviewServed-tier: 113/113 N = assistant request rows in the isolated reviewer's transcript, every one stamped Head-sha:
Read from the PR API at start and again at the end: unchanged. It is a merge commit ( ① Derived judgmentsAccept/reject set — the request schema. Accept/reject set — the wire. (a) Exported surface. Registry and generated mirrors. Extracted the D3 object from The three cross-surface facts the previous rounds failed on, re-measured. Version: prescription "@objectstack/spec 17.5.0"; generated reference the same; Changed without needing to. Nothing of substance; the ② Semver grade vs. the changeset's declaration
③ Boundary flags
Ground 1 — VERDICT: FAIL Implemented-by: 交接 —— 载体已剥;并附本席对自己一条已发布评论的更正Ground 1 本席第一手复测,成立。 三个 run-list 发射点的判据在同一个文件里就不一致: ⇒ 携带那句承诺的,恰恰是会静默替换的那一个:
|
…s; 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>
|
os-dev-report {
"issue": 19543,
"note_on_the_card": "#19365 is permanently 404 and #19543 replaces it. I verified both myself: GET issues/19543 -> 200, state open, title '[finding] three sibling list doors declare `limit`/`cursor` and never …' (the rebuild); GET issues/19365 -> 404. Also verified #19545 -> 200 (the rebuilt #19361). So the number I re-pointed to RESOLVES — I did not trade one dangling reference for another.",
"status": "done",
"round": "PM loop round 2 — contract-review remediation round 4",
"branch": "claude/issue-19365-automation-runs-hasmore",
"pr": "https://github.com/objectstack-ai/objectstack/pull/19493",
"head_for_re_review": "ae87f1fde8e6748bdb29666c07171537a8f352c4",
"previous_head_that_FAILed": "99ad620c544e1f3f4e0976596a6e8a7e4620d70e",
"session": "session_01UDXER3sdqfeVYpEWZs5mZx",
"premise_still_valid": true,
"the_FAIL_ground": {
"verdict": "CONFIRMED, and the sentence was mine again — third rewrite, third false sentence.",
"option_taken": "(b) — narrowed the sentence. ⛔ NOT (a), and the reason is not timidity: the truthy guard `if (options?.limit)` at :5554 is PRE-EXISTING. I measured it at the merge-base (eec56c3) and it is there, and my diff touches no `options?.limit` line at all. So (a) would have been an unauthorised SDK behaviour change — the ruling covers retiring `cursor`, not `limit`'s client-side handling — made inside a round whose entire job is to stop introducing things. I broke a sentence; I fixed the sentence.",
"⭐_I_PROVED_IT_BY_EXECUTION_THIS_TIME": "The failure mode for three rounds has been that I verify sentences by READING code. This round I ran all three emitters against eight edge inputs through the real client with a mocked fetch, and read the emitted URLs. Temporary probe, deleted after — it is not in the diff.",
"the_measured_table": {
"automation.runs.list (truthy guard)": "undefined, null, 0, NaN -> NO `?limit=` sent. -5, 1.5, 101, 20 -> sent.",
"automation.listRuns (!= null)": "undefined, null -> not sent. 0, NaN, -5, 1.5, 101, 20 -> sent.",
"ScopedEnvironmentClient.automation.listRuns (!= null)": "identical to the alias — 0 and NaN ARE sent.",
"what_that_proves": "The sentence was false at exactly the FALSY-but-out-of-range inputs, which is `0` AND `NaN` — the review named `0`; `NaN` is the same class and I would have missed it by reasoning about `0` alone. Everything truthy is sent and refused, so the rest of the sentence held."
},
"what_it_says_now": "«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 … ⚠️ `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`.»",
"every_clause_maps_to_a_measured_row": "There is no clause in that paragraph that the probe did not exercise."
},
"the_lesson_I_am_carrying_out_of_this": "Three rounds, three false sentences, all born in a rewrite, and the through-line is sharper than 'be careful'. Round 2: I fixed an arguable word by asserting `optional` without reading the declaration — I trusted a defensive `??`. Round 3: I fixed `clamped` by moving the claim across the refuse/clamp boundary and landed just outside it on the other side. Round 4 is the correction to the method, not the sentence: ⭐ **when a sentence describes a guard, enumerate the guard's inputs and EXECUTE the sentence at each edge.** `0`, `NaN`, `''`, `null` and `undefined` are where a truthy guard and a `!= null` guard stop agreeing, and no amount of reading the line tells you which of them your prose just promised something about. The probe took four minutes and would have caught all three rounds.",
"same_stroke_tidy_up": "«read by nothing on the server» is now «validated at the boundary and read by nothing beyond it», matching the prescription's own wording — the boundary DID read the key, to validate it. ⛔ I did not touch the identical phrase at :6472, which is the inbox door's own pre-existing sentence and true there.",
"card_re_pointing": {
"derived_myself_not_taken_from_the_breakdown": "As instructed. My derivation over the PR's 20 files: 41 occurrences of `#19365` across 17 files. Every one was `#19365`; a correctly-written bare-number probe (my first attempt was broken — `grep -o` emits only the match, so the `grep -v` filter never applied, and I redid it) found ZERO bare occurrences in file contents.",
"reconciles_with_the_gate_count": "The gate's 22 is the comment-prose subset and my 41 is the whole set; the 19-item difference is exactly the categories the gate blanks — 15 in test files, 1 changeset, 1 flows.mdx row, and 2 string literals (the D3 `reason` and its registry mirror). Per-file my counts match the gate's breakdown exactly, with one explained divergence: the gate says registry.ts ×1 and I count 2, because registry.ts mirrors BOTH the retired-key comment (judged) AND the semantic entry's string literal (blanked).",
"what_I_changed": "All 41 — 39 hand-written across 15 files, plus 2 regenerated into registry.ts with `gen:migration-registry`, ⛔ never by hand. Verified after: ZERO `#19365` remain in any file of the diff, and `#19543` now totals 41.",
"⛔_6361_untouched": "Counted before and after: 13 occurrences, identical. Not among the gate's sites, still 404, and not reconstructable — left exactly as written.",
"⛔_annotation_remedy_NOT_used": "A target that resolves now exists, so the gate's own text prefers naming it. I did not write any 'this number no longer resolves' prose.",
"one_thing_I_did_NOT_rename_and_why": "`.changeset/19365-automation-runs-cursor-hasmore.md` keeps its FILENAME. The citation inside it is re-pointed; the filename is not a reference any reader or gate resolves, and renaming it is churn with no consumer. Flagging it so the number appearing in a path is not mistaken for a missed site.",
"⛔_PR_body_untouched": "`Part of #19365` on line 1 is the seat's to edit, as stated. I did not touch the body."
},
"verification": {
"the_citation_gate_went_GREEN": "⭐ `Lint & Repo Gates` on ae87f1f: completed/success. I polled it to convergence rather than reporting an `in_progress` as a pass — it sat in_progress for ten polls and turned green on the eleventh. So the re-pointing cleared the blocker on its own, as predicted, and ⛔ I did not touch or re-run it at any point.",
"ci_overall": "33 distinct check names on ae87f1f at last read: 24 success, 4 skipped, 5 still in_progress (`Test Core` 1/3/5/6 and `Type Check · workspace`). ZERO failures. The in_progress five are NOT MEASURED, ⛔ not passes.",
"tests": "All four affected packages under the lock, exit codes captured before any pipe, VERDICT command-exit 0: @objectstack/spec 509 files / 14891 passed + 1 todo, @objectstack/service-automation 141 / 1690, @objectstack/runtime 272 files / 3799 passed + 1 skipped, @objectstack/client 49 / 569.",
"generated_artefacts": "`check:generated` after the spec rebuild: all 15 up to date.",
"the_executable_surface_of_this_round": "Measured rather than asserted: per-file, the non-comment changed lines are 0 for EVERY production source file — client/src/index.ts, runtime/domains/automation.ts, service-automation/engine.ts, spec/contracts/automation-service.ts and spec/api/automation-api.zod.ts all show 0. The only non-comment changes anywhere are markdown rows (changeset title, flows.mdx row), five `describe`/`it` TITLE strings in tests, and two string literals in the migration entry and its mirror. I ran the full suites anyway."
},
"mcp_calls": "0 — no MCP GitHub tool at any point across six rounds. All GitHub reads and writes via the REST proxy with curl.",
"api_writes": "1 this round — POST /repos/objectstack-ai/objectstack/issues/19493/comments (this report, on the PR). ZERO label writes. ⛔ No PR-body edit. Pushes this round: 1 (99ad620..ae87f1f), made as soon as the edits were verified.",
"open_questions": [],
"out_of_scope_findings": [
"noted, not filed, and it is the finding the FAIL ground sits on: the three run-list emitters in packages/client/src/index.ts do not agree about falsy `limit`. `automation.runs.list` guards on truthiness and silently drops `0` and `NaN`; the two `listRuns` surfaces guard on `!= null` and send them. So the SAME logical call answers `200` with 20 runs through one surface and `400 VALIDATION_FAILED` through the other two, for the same input. That is a silent substitution — the card's own defect class — but it is PRE-EXISTING (present at merge-base eec56c3, untouched by this PR) and fixing it is an SDK behaviour change no ruling covers. It is now documented in place rather than left to be rediscovered. ⭐ Worth a card of its own: the divergence is repo-wide, not local — `grep params.set('limit'` finds 11 emitters in that file across FOUR different guard spellings (`truthy`, `!= null`, `!== undefined`, and the inbox door's truthy), so whoever takes it should take the family, not this one line. Who will hit it: the door ②/③ act, which edits the same file.",
"carried forward, unchanged and untouched: index.ts:6640 (ai.conversations.list) is door ③'s; :3241/:3244 (listRevisions) is a different domain; ListRunsResponseSchema.nextCursor stays declared and never emitted; the ?status= residual is pre-existing and stated in six consumer surfaces; the fourth sibling door (ListFlowsRequestSchema) is the seat's to file."
],
"deviations": [
"SCOPE (chosen and reported): given the explicit either/or, I took (b) and ⛔ not (a). Reasoning above — the guard is pre-existing and changing it is an unauthorised behaviour change. The consequence is that a real pre-existing inconsistency stays open; it is filed above as an out-of-scope finding with the family-level evidence rather than silently absorbed.",
"MEASUREMENT (reported): my first bare-number probe was broken (`grep -o` output defeats a following `grep -v`), which would have told me there were no bare occurrences whether or not that was true. I noticed, redid it correctly, and the corrected reading is what I acted on. Reporting it because a broken instrument that happens to give the right answer is exactly what I was criticised for last round.",
"PROCESS: a temporary probe file was written into packages/client/src and deleted after the reading; it is not in the diff and I verified its absence."
],
"ci_status": "ae87f1f: 24 success / 4 skipped / 0 failure / 5 in_progress across 33 check names. `Lint & Repo Gates` — the blocker for the last two heads — is completed/success. The five in_progress are NOT MEASURED."
}Generated by Claude Code |
Contract reviewServed-tier: 100/100 N = assistant request rows in the isolated reviewer's transcript, every one stamped Head-sha: Read from the PR API at start and again at the end: unchanged, still draft, no auto-merge, zero formal reviews. Merge-base with ① Derived judgmentsThe round-3 FAIL ground, re-taken by execution rather than reading. I ran the head's Guard provenance (claim 2). Accept/reject set and public surface vs merge-base — unchanged from the rounds that measured them, re-read at this head. Card re-pointing (claim 3), measured with a lit control. Executable surface (claim 4). Every changed line this round in Nothing left inconsistent across the surfaces carrying one fact. Version: prescription, generated reference, ② Semver grade vs. the changeset's declaration
③ Boundary flags
VERDICT: PASS Implemented-by: 交接 —— PASS,双载体同笔已剥;落地前置三条逐条在案这是第五轮。 前四轮:轮 1 达档 FAIL、轮 2 FAIL、轮 3 FAIL、轮 4 无 FAIL 但被板上事故挡住。三次 FAIL 都是一句假话,都诞生于一次改写。 ⭐ 让这一轮不同的不是更小心,是换了验证方式。 前三轮都用读代码来验句子;这一轮 dev 与复核各自独立地执行了它——把三个发射点对十五个边界输入跑过真客户端,再把发出去的每一个字符串喂给真的 落地前置(
|
| 条件 | 状态 | |
|---|---|---|
| ① | 达档条款②复核 PASS 在案 | 本记录,Served-tier: 100/100,所判 head ae87f1fde8e6 |
| ② | 双载体已清 + --pair 机读 |
本笔剥标;剥前 --pair 19493 = exit 0 |
| ③ | PR check 全绿 | 35 个名字,31 success / 4 skipped,非绿 0、在跑 0;七个必过上下文全 success |
--pair 当时 exit 4。本席已在 #19543 上补回认领(Clause-②: yes 一行抄自模板,⛔ 非凭记忆),才有现在的 0。⇒ 重建一张卡 = 恢复工作项 + 恢复它的协议载体,两件事。
复核点名归本席的三件,逐件处置
{ limit: 0 }的跨面分歧要立卡 —— 已立,[finding]@objectstack/client's 11limitemitters guard three different ways, so the same{ limit: 0 }is silently dropped on some doors and refused with400on others — and{ limit: null }is sent as the stringnullon six of them #19567(复核起跑时它还不存在)。本席自取的普查比 dev 报的更糟:11 个发射点、三种行为,另加一条 dev 没点到的——六个!== undefined守卫会把String(null)即字符串"null"发上线。- 正文里
#19361仍是 404 —— 它已重建为 [reading request from domain:spec] REBUILD of #19361, which stopped resolving on 2026-09-21 — the original request text did NOT survive and its riders must restate what they need #19545,本席同笔在正文与卡上改指。 #19364也是 404 —— ⛔ 不改指:它是一张已合并的 PR,不是卡,没有等价目标;按门禁自己的 REMEDY 保留号码并在散文里写明。
⛔ 卡 #19543 保持 open:它带着门②门③,而本 PR 只关门①(首行 Part of,⛔ 非 Fixes)。
CONTRACT_REVIEW_TIER (the tier in force) and RETIRED-TIER (the tier retired on 2026-09-21T10:22:54Z by 77df0f61a6 / PR #19573), per the AGENTS.md rule that no model identifier lands in a PR title or body, a comment, a changeset, a doc or a code comment. ⛔ No judgment, figure, coordinate or verdict was changed. Inventory and the standing rule conflict: #19615.
Generated by Claude Code
Landing provenance — all three preconditions in case, PR is ready, and the enqueue is NOT done
Done in this act: the PR is out of draft ( ⛔ NOT done: entering the merge queue. The call was refused by this session's permission layer, reason ⇒ What is left is one action, and it is not a judgement: put this PR into the merge queue. Everything a lander is supposed to check first is above, in case and re-readable.
Generated by Claude Code |
✅ Correction — this PR is NOT blocked any more. It is in the merge queue.
The maintainer re-granted the permission and the call was retried. ⛔ The earlier standing-down note on this PR — "
|
Part of #19543
Clause-②: yes
Door ① of three.
GET /api/automation/:name/runsdeclared a paginationparameter 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
⚠️ and neither that comment nor that card resolves any more — #19365 was removed from
5754491070on #19365 records decision batch #204 item 2,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
reasonfield of the D3 entry inpackages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts. lettersC · C · Aper door, maintainer 「204 同意」 2026-09-21. For door ① theruling reads, verbatim:
⛔ Not re-adjudicated here. Letter A — building a cursor protocol — is
explicitly not taken, so no continuation token is minted and
nextCursorstaysabsent.
Why
Part ofand not a closing keyword. Doors ② (export jobs) and ③ (AIconversations) 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.mjsis the mechanical half ofthat, 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/mainat5e7d83c, not relayed.cursordeclared, never readListRunsRequestSchemadeclared it;AutomationEngine.listRunsnever looked at the option; no emit site writesnextCursorhasMorehard-codedautomation.tsreturneddeps.success({ runs, hasMore: false }), a literal, besidemerged.slice(0, limit)limitdeclared, never read.default(20)unique to the export doorListRunsRequestSchemacarries it toolimitis read at the boundary (parseIntegerParam, with the1..100boundstaken off the schema itself), forwarded to
IAutomationService, and spent bythe engine as
RunStore.listHistory's window. It is also pinned by liveenforcement in
automation-runs-query-validation.test.ts. Retiring it wouldhave been a regression, not a narrowing, and the ruling says the
/packagesparent ruling
5651023067does not transfer. Both corrections belong on thecard's thread, which is the census.
What "truncated" means at this seam
The tempting signal is
runs.length === limit. It is wrong at exactly oneinput, and that input is undetectable from the response: a flow holding
exactly
limitruns produces a window byte-identical to one held by a flowwith ten thousand. Reporting
truefor the first is as wrong asfalseforthe second.
Only one of the three sources
listRunsmerges was ever capped — the durablehistory arm, because
RunStore.listHistory(flowName, limit)takes the windowas 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 againstlimit. Overflow means a run matched that this window does not carry. Theextra row is dropped by the same
.slice(0, limit)that was always there, sonothing on the wire widens.
⛔
RunStore.listHistory's signature is deliberately not redesigned:over-reading is expressible in the
limitit already takes, so the truncationsignal costs the store contract nothing.
Two things
hasMoredeliberately does not mean, both pinned:does not exist any more; it is not "more" and no
limitbrings it back.remedy is a wider
limit, up to the declared 100.One honest residual, pre-existing and unchanged. Under
?status=, thehistory arm's window is still the newest
limit + 1rows of any status,because
listHistoryhas no status slot and the filter is applied to whatcomes back. A status-filtered
hasMore: falsetherefore means "no furthermatch 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=banswered400 VALIDATION_FAILED; it now answers200with 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.
hasMorecan now betrue. A request whose window is shorter than thematching run set receives
truewhere it previously receivedfalse. A callerthat read
falseas "this is the whole history" was always wrong and is nowtold so.
3. A service implementing no
listRunsPageanswers501naming themember, never a
200carrying a guessedhasMore. "Absence must be loud" —falling through to the domain's
404would leave a caller unable to tell "norun listing is mounted here" from "no such flow". The
403run-read grant runsahead of the service probe and is unaffected, which is what that gate's own
note already required.
Shape of the change
cursor: retiredKey(RUNS_LIST_CURSOR_REMOVED). A tombstone, not adeletion: the request schema is not
.strict(), so a bare deletion makes Zodsilently 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
(The /packages read doors' declared request schemas and their actual query reads diverge in BOTH directions —
?limit=and?cursor=are declared and never read,?type=is read and never declared #17667 / PR feat(spec): the /packages doors declare the query parameters they execute, and retire the two they never did #19364 — that PR number no longer resolves and has no rebuild, being a merged PR rather than a card; card The /packages read doors' declared request schemas and their actual query reads diverge in BOTH directions —?limit=and?cursor=are declared and never read,?type=is read and never declared #17667 resolves and is the live record) rather than invented.IAutomationService.listRunsPagereturning theexported
RunListResult({ runs, hasMore }) — the shapeIExportService.listExportJobsalready uses, minus the cursor nothing mints.cursorleaveslistRuns's options in the same stroke.listRunsPageholds the whole method;listRunsis itsrunshalf. ⭐ One implementation, two projections, so there is no second
merge/filter/sort to rot. This is also why ~120 existing
listRunscallsites across
service-automation,plugin-approvals,examples/andpackages/cliare untouched.RETIRED_KEYS_BY_MAJOR[18]entry plus the D3 semantic entryautomation-runs-cursor-retired. No D2 conversion: a conversion rewrites anauthored source or a stored
sys_metadatarow, and this shape is HTTP-only.Registered at 18, not 17, per the sibling convention.
minoracross the three published packages, carrying theADR-0087 disposition
registered automation-runs-cursor-retired.content/docs/automation/flows.mdx's REST route table advertised?cursoron thisroute. 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
hasMoreiscomputed with a wider
?limitas the remedy. Flagged by Docs Drift Check (5755158989); theother 10 pages it named document the DATA door's
hasMoreand are true as they stand, so nonewas edited. Written by the dispatching seat, not the implementer — the implementer's one body
write was spent at create.
@objectstack/clientdeclaredcursorand appended?cursor=on all three run-listsurfaces (
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
stringand sending it into a route that no longer reads it — the ADR-0104 silent strip the tombstoneexists 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': minorjoins the changeset. Samecall the repo made when GET /api/v1/notifications 从不解析它声明的请求 schema ——
cursor被静默丢弃(SDK 分页永远第一页),limit默认 20 声明 vs 50 实现 #6361 retired the notificationscursor. Added by the dispatching seat after theat-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 assertionthat moved is named. Removed: the
#7300cursor-refusal describe (3parametrised cases) and 3
?cursor=preservation rows — superseded, notdeleted, with the replacement asserting the opposite on the same inputs.
Added: 6 retirement cases and 3
hasMore-relay cases. Changed: the doublenow serves
listRunsPage, andcursor: undefinedleft 10 expected optionsobjects. The
limitpreservation 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.
run-list-truncation.test.ts(14 cases) pins the boundary table —fewer than / exactly / more than
limit— plus a spy proving the storeis asked for
limit + 1.pnpm test: runtime 271 files, service-automation 141 files / 1690 tests.pnpm typecheck: spec, runtime, service-automation — all green, no newtest-typecheck-debt.jsonentries.scripts/pm/dispatch-gates.mjs --commands, reconciledwith
--ran): 112 derived · 110 exit 0 · 2 exit 3 (NOT MEASURED) · 0unrun. Exit codes were captured before any pipe. The two are environmental
refusals, ⛔ not findings and ⛔ not passes:
check-plugin-teardown-shape --self-testcannot reach a commit-pinnedpositive control in a shallow checkout (
--is-shallow-repositoryistruehere; the gate itself ran, exit 0), and
check:dual-build-cjs-loadsrefuses without a repo-wide build (38 packages carry no
dist/). CI hasboth. 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) andcheck:type-check-debt(4 ledger entries re-measured, 53 raw errors, noneabove its recorded number).
Serial constraints
Declared adjacency from the dispatch: PR #19373 holds
packages/spec/dropped-refinements.baseline.json,packages/spec/api-surface/root.jsonandpackages/spec/export-origins/root.json. This PR moves none of those three— regeneration landed on the
contractsshards(
api-surface/contracts.json,export-origins/contracts.json) plusauthorable-surface/api.json, all disjoint.origin/mainwas merged beforethis reading and
check:generatedreports all 15 artefacts current.Acceptance notes
Out of scope, observed, ⛔ not filed and ⛔ not widened into this PR:
ListRunsResponseSchema.nextCursorstays 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 literalhasMore: false.Measured, and there it is true: the handler returns every name with
total === names.length, so nothing is withheld. Recorded so the nextreader does not read the two literals as the same defect. No card.
?status=window residual described above is a real narrowing ofwhat
hasMore: falsecan promise. It is pre-existing, it is the engine's ownrecorded limitation, and closing it is a
RunStorecontract change — theruling 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 "enginereports truncation to the route" needs the contract member the route calls),
and two
packages/runtimetest doubles that stub the run-list service —http-dispatcher.test.tsandautomation-run-read-permission-gate.test.ts.Generated by Claude Code