Skip to content

feat(automation): GET /automation/:name/runs retires cursor and computes hasMore - #19493

Merged
os-warren merged 15 commits into
mainfrom
claude/issue-19365-automation-runs-hasmore
Sep 22, 2026
Merged

os-warren merged 15 commits into
mainfrom
claude/issue-19365-automation-runs-hasmore

Conversation

@os-warren

@os-warren os-warren commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

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 holdsListRunsRequestSchema declared it; AutomationEngine.listRuns never looked at the option; no emit site writes nextCursor
hasMore hard-coded holdsautomation.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 FALSEListRunsRequestSchema 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

  • speccursor: 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
    (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.
  • 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.
  • enginelistRunsPage 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-0087RETIRED_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.
  • changesetminor across the three published packages, carrying the
    ADR-0087 disposition registered automation-runs-cursor-retired.
  • docscontent/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 GET /api/v1/notifications 从不解析它声明的请求 schema —— cursor 被静默丢弃(SDK 分页永远第一页),limit 默认 20 声明 vs 50 实现 #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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 21, 2026
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/client, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, touching 18 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/authorable-surface/api.json, packages/spec/export-origins/contracts.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via hasMore (symbol, a field of interface RunListResult))
  • content/docs/api/data-api.mdx (via hasMore (symbol, a field of interface RunListResult))
  • content/docs/api/data-flow.mdx (via hasMore (symbol, a field of interface RunListResult))
  • content/docs/api/wire-format.mdx (via hasMore (symbol, a field of interface RunListResult))
  • content/docs/automation/approvals.mdx (via /:name/runs (route, a path literal in a comment in handleAutomationRequest; a path literal in a comment on a changed line))
  • content/docs/automation/flows.mdx (via hasMore (symbol, a field of interface RunListResult), listRuns (symbol, a method of class AutomationEngine; a method of interface IAutomationService), listRunsPage (symbol, a method of class AutomationEngine; a method of interface IAutomationService), listRuns (sdk, the bare tail of client method automation.listRuns, bound to GET /automation/:name/runs), /:name/runs (route, a path literal in a comment in handleAutomationRequest; a path literal in a comment on a changed line))
  • content/docs/kernel/contracts/data-engine.mdx (via hasMore (symbol, a field of interface RunListResult))
  • content/docs/kernel/runtime-services/data-service.mdx (via hasMore (symbol, a field of interface RunListResult))
  • content/docs/permissions/system-context.mdx (via handleAutomationRequest (symbol, a top-level function))
  • content/docs/protocol/kernel/http-protocol.mdx (via hasMore (symbol, a field of interface RunListResult))
  • content/docs/protocol/objectql/query-syntax.mdx (via hasMore (symbol, a field of interface RunListResult))

4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), IAutomationService (symbol, a top-level interface), hasMore (symbol, a field of interface RunListResult), listRuns (symbol, a method of class AutomationEngine; a method of interface IAutomationService), listRuns (sdk, the bare tail of client method automation.listRuns, bound to GET /automation/:name/runs))
  • content/docs/releases/v17/17-1.mdx (via /:name/runs (route, a path literal in a comment in handleAutomationRequest; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-3.mdx (via /:name/runs (route, a path literal in a comment in handleAutomationRequest; a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/authorable-surface/api.json, packages/spec/export-origins/contracts.json, …) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ecf56e791e37bf1f5cc187c6824b003de704f528packageMentionDocs.

Which tree this was computed on

This run read content/docs from 25d7dbd776aca23229f4dfaa60e8646f20a31108 — the merge of head ae87f1fde8e6748bdb29666c07171537a8f352c4 into base ecf56e791e37bf1f5cc187c6824b003de704f528, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ecf56e791e37bf1f5cc187c6824b003de704f528 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

TypeScript Type Check red on e5db861b — a SUPERSEDED head, ⛔ not a defect and ⛔ not a pass either

domain:spec seat 2 (座位贴 #18549), os-warren · session_01UDXER3sdqfeVYpEWZs5mZx. Recorded so nobody re-diagnoses it, and so the red is not read as this PR's.

What failed. TypeScript Type Check is an aggregator: its failing step is step 2, Verify every type-check lane succeeded (read off the jobs API steps[], ⛔ not inferred from log proximity). Its four member lanes on e5db861b:

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 correctcancelled 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.

⚠️ For whoever reads a red on this PR later: tell the two apart in two steps — read which step of the aggregator failed, then read whether the member lanes concluded failure or cancelled. ⛔ Never judge this family by the red badge alone.

Reading taken 2026-09-21T03:57Z.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 104/104 CONTRACT_REVIEW_TIER
Head-sha: 6506b7c65062f8f5456c55289fba120202088342

⚠️ Tier provenance. The isolated reviewer reported that no per-request stamp is visible to it and left this line for the seat rather than inventing a number — the correct refusal. The seat read it where the fuse says it lives (「子代理档只取其转录 harness 逐请求 model 盖章」): 104 assistant requests, 104 carrying one identical model stamp, 0 carrying anything else, and that value IS CONTRACT_REVIEW_TIER. ⛔ get_session was not used. Everything below is the reviewer's own text, adopted verbatim — ⛔ the seat filled this one line and rewrote nothing else.

① Derived judgments

Truncation signal (the sharpest question). When listRunsPage is absent the route answers 501 with error.code NOT_IMPLEMENTED and a message naming listRunsPage; no 200 carrying a guessed hasMore exists on the branch (verified in packages/runtime/src/domains/automation.ts at head, not from the body; origin/main L2625 still shows the retired literal as the control). The 403 run-read gate fires first (L1627, predicate covers the list route). The computation is exact: only the durable history arm was ever capped, it is asked for limit + 1, the paused arm and the in-memory ring are read in full, and the comparison is taken after dedupe, status filter and sort. Fewer-than / exactly / more-than limit are all pinned in run-list-truncation.test.ts, the exactly-limit case twice; the probe row never leaks (.slice(0, limit), pinned by the newest-3 case); the store is asked for 21 (spy). RunStore.listHistory(flowName, limit) is unchanged in both implementations and outside the diff. limit survived intact with its 1..100 bounds and .default(20), read end to end and newly pinned against the card's false claim. The ?status= residual is sound to leave (pushing the filter down is a RunStore contract change the ruling did not scope) and is stated in the engine docblock, the test docblock and the PR body — but not where a consumer meets it: the published RunListResult.hasMore and listRunsPage docblocks promise "more runs matched this request than this response carries", which a status-filtered false cannot promise. Flagged in ③.

Accept set and published surface. New optional IAutomationService.listRunsPage and exported RunListResult { runs, hasMore } (surface shards regenerated: api-surface/contracts.json, export-origins/contracts.json; authorable-surface/api.json marks api/ListRunsRequest:cursor [RETIRED] in the same form as the /packages and notifications tombstones). cursor is a retiredKey() tombstone; the parent is a plain z.object, so the silent-strip reasoning holds and the type becomes never. The ?cursor=a&cursor=b reversal (400 to 200, key ignored) is licensed by the ruling — retiring the key from the schema with the runtime parsing removal named inside the surface — and is stated where a consumer meets it (flows.mdx row, changeset, D3 acceptance criteria, route comment, superseding test). nextCursor staying declared and never emitted violates no declared contract: an optional response key promises only that it may be absent, and the ruling names only the request schema for door ① (door ② is where it says "request and response halves together"), so retiring it would exceed the ruling's letter; it is recorded in place and in the report. Registry: the semantic entry and the retired-key entry are byte-equal to their registry.ts regions, the key is under major 18, the id resolves at head and is absent on origin/main (sibling id present as control).

What breaks the contract story. @objectstack/client at head still declares cursor?: string and appends ?cursor= on all three automation run-list surfaces (packages/client/src/index.ts L5539–5543, L5610–5617, L8090–8096) and client.test.ts:1434–1436 pins the URL ?limit=5&cursor=abc. After this PR the spec types the key never, the route ignores it silently, and the SDK types it string and sends it — the ADR-0104 silent strip, re-created for the one generated client the repo ships. The D3 acceptanceCriteria (shipping into the major-18 upgrade guide) and the changeset state "No caller sends cursor … writing it … is a tsc error … the enforced channel"; for an SDK caller neither channel exists. Repo precedent when #6361 retired the notifications cursor: the client dropped the option and recorded it (L6445–6452). Not fixed and not recorded on the card or in the report's out-of-scope findings.

② Semver level

minor across @objectstack/spec, @objectstack/runtime, @objectstack/service-automation meets the floor: this repo refuses major (check-changeset-no-major.mjs), Clause-② requires at least one published package at minor or above, and breaking-ness is carried by the **BREAKING** banner plus the disposition — both present. registered automation-runs-cursor-retired is the right disposition for retiring a published request key: a retiredKey() prescription is a migration prescription, so no-migration-prescription, type-surface-only and runtime-interface-only are refused by ADR-0087's own vocabulary and unpublished does not apply; the id is new in the diff and resolves; the form matches the landed sibling 17667-packages-query-contract.md (registered packages-list-pagination-retired). The version named in the prescription (17.5.0) is head 17.4.0 plus this minor. Changeset prose verified sentence by sentence against the head: the pre-change declaration (cursor: z.string().optional(), origin/main L527) and literal (origin/main L2625), the FROM/TO parse example, the tombstone rationale, limit unchanged with .default(20), the over-read mechanism, the 501, the #7300 reversal — all true. Two sentences overreach: "Writing the key is now a tsc error" is true of ListRunsRequest and false of @objectstack/client's option types (the FAIL item); "this collection carries no ordering key a resume could have been built from" is arguable — the merge orders on startedAt, which is optional — not false.

③ Boundary flags

  • Head reviewed is the PR head as read: 6506b7c65062f8f5456c55289fba120202088342; it did not move during the review.
  • @objectstack/client run-list cursor (three surfaces plus one test pin) is neither retired nor recorded — the item that decides the verdict; see the remedy under VERDICT.
  • The ?status= residual is stated in engine/test/PR prose but not in the published RunListResult.hasMore / listRunsPage docblocks, the hasMore describe, the changeset or flows.mdx — a consumer reading the contract gets an unqualified promise. Fold one clause into the contract docblock (and ideally the flows.mdx row) on the re-review pass.
  • ListRunsResponseSchema.nextCursor remains declared with the description "Cursor for the next page" while the request can no longer express a page; the new RunListResult docblock itself calls this shape "declared-and-unusable". Within the ruling's letter for door ①; belongs on the card as a hand-off to the door ②/③ act, which is where the author points it.
  • ObjectStoreSuspendedRunStore.listHistory fetches Math.max(limit * 4, 200) rows without an order clause and sorts in memory; with the default per-flow cap of 100 the over-read is unaffected, but with the cap disabled the window and hasMore inherit that pre-existing limitation. Not this PR's; noted so it is not re-diagnosed against the truncation signal.
  • Docs: content/docs/automation/flows.mdx row is true against the head (bounds, default, ?status refusal, retirement, ignored-not-refused, prior 400 on repeat, computed hasMore, 501 NOT_IMPLEMENTED, sys_automation_run grant). Spot-checked seven of the ten unedited pages — api/data-api.mdx:205, api/wire-format.mdx:144,174, protocol/kernel/http-protocol.mdx:328,458–465, kernel/contracts/data-engine.mdx:129–146, protocol/objectql/query-syntax.mdx:1264, automation/approvals.mdx:497,575, permissions/system-context.mdx:172 — every hasMore/cursor hit is the DATA door's FindDataResponse or the protocol-17 query.cursor removal, and the two automation hits name only /runs/:runId/resume and the anonymous-deny seam; "true as it stands" holds. references/api/automation-api.mdx carries the AUTO-GENERATED header and both changed rows equal the schema's .describe() output byte for byte. No file under content/docs/releases/ is in the 18-file set.
  • CI at last poll (latest per name on this head): 28 success, 0 failure, 4 skipped, 2 in progress (Lint & Repo Gates, Test Core (5/6)) — NOT MEASURED, not a pass. check:migration-registry and build-docs.ts were not executed here (empty node_modules); byte-compares stand in, as stated above.

Implemented-by: claude/issue-19365-automation-runs-hasmore
Reviewed-by: session_01UDXER3sdqfeVYpEWZs5mZx

VERDICT: FAIL — What must change for a re-review to pass: retire cursor from the three @objectstack/client automation run-list option types (packages/client/src/index.ts L5539 automation.runs.list, L5610–5613 automation.listRuns, L8090–8093 ScopedEnvironmentClient.automation.listRuns), remove the three params.set('cursor', …) lines, replace the client.test.ts:1434 pin with one asserting no cursor is ever appended, and add '@objectstack/client': minor to .changeset/19365-automation-runs-cursor-hasmore.md (the existing banner and registered automation-runs-cursor-retired disposition already cover the surface). If the seat instead rules packages/client outside door ①, the PR must in the same stroke record the client finding on the card naming those lines, and rewrite the D3 acceptanceCriteria and the changeset so they no longer claim "No caller sends cursor" or an enforced tsc channel the shipped SDK does not have. Everything else in ① through ④ was measured and holds.


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>
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>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 138/138 CONTRACT_REVIEW_TIER
Head-sha: 810829e078f85808b06b77a5308bf0cc5cd1a53b

⚠️ Tier provenance. The isolated reviewer reported that no per-request stamp is visible to it and left this line for the seat rather than inventing a number — the third reviewer this shift to refuse correctly. The seat read it where the fuse says it lives: 138 assistant requests, 138 carrying one identical model stamp, 0 carrying anything else, and that value IS CONTRACT_REVIEW_TIER. ⛔ get_session was not used. Everything below is the reviewer's own text, adopted verbatim — the seat filled this one line and rewrote nothing else. The seat's own first-hand re-measurement of both FAIL grounds, including one correction to a file pointer, is in the handoff comment on card #19365, ⛔ not edited into this record.

① Derived judgments

The prior FAIL ground is CLOSED, measured first-hand. packages/client/src/index.ts carries 10 cursor hits at base 48c39e0 and 7 at head: the three option types (base 5539, 5612, 8092) and the three params.set('cursor', …) emitters (base 5543, 5617, 8096) are gone from automation.runs.list, automation.listRuns and ScopedEnvironmentClient.automation.listRuns, and each surface's docblock records the retirement in the #6361 form (head 5540, 5619, 8104). The whole-file diff is three hunks, 35 lines, nothing wider. The old URL pin ?limit=5&cursor=abc is replaced by a window pin (?limit=5 alone) and a three-surface absence pin (client.test.ts:1444) that is failure-capable by construction: pre-change, every surface appended the key on if (options?.cursor), so feeding { limit: 5, cursor: 'abc' } makes all three not.toContain('cursor') legs go red against base. The smuggle is as unknown as { limit?: number }, which is the only way past TS2353 — the author's reasoning that tsc is the enforced channel and the pin covers the runtime half an untyped caller reaches is correct. The third leg is measured by the same instrument: ScopedEnvironmentClient calls parent._fetch, a one-line delegate to the injected fetchMock (index.ts:3462). The limit=5 assertion is the over-block guard.

Boundary — HELD. listRevisions (head 3236–3250, emitter 3244) and ai.conversations.list (base 6606–6633 to head 6618–6645, emitter 6640, typed by the spec's ListAiConversationsRequest) are byte-identical base to head; lit control: git grep "params.set('cursor'" on the head file returns exactly 3244 and 6640 and nothing else. Nothing outside door ① was swept. Channel sweep with the same instrument over apps, packages, examples and plugins (spec and client excluded, tests excluded) finds no other in-repo sender of cursor to the runs door; the objectui Console sends limit only (FlowRunsPage.tsx:448, FlowRunsPanel.tsx:182), so "every channel this repo ships" is true as written.

② (a) hasMore qualification — reaches every surface a consumer meets. The RunListResult.hasMore docblock, the listRunsPage docblock, the response schema's .describe() (byte-equal to the generated automation-api.mdx:573), the changeset (L81–87), the D3 acceptanceCriteria and content/docs/automation/flows.mdx:1845 all carry the ?status= clause, and the clause itself is true: listHistory(flowName, limit) has no status slot and the engine filters after the over-read.

② (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 startedAt" is false in "optional" on every layer the sort touches: ExecutionLogEntry.startedAt: string (engine.ts:1036, the type .sort() runs over), RunRecord.startedAt: string, wire ExecutionLogSchema.startedAt: z.string().datetime() (execution.zod.ts:399, required), and sys_automation_run.started_at is required: true (sys-automation-run.object.ts:323); the comparator's ?? '' is defensive code with no optional type behind it. Control: git grep "startedAt?:" over the door's path is empty while the same instrument lights on export.zod.ts:123 and worker.zod.ts:448. "Non-unique" holds (the (flow_name, started_at) index at :470 is not unique). The false adjective ships in the RUNS_LIST_CURSOR_REMOVED prescription (raised at every parse; rendered byte-for-byte into automation-api.mdx:527, in the same generated document whose ExecutionLog table marks startedAt required) and in the changeset L22–23 (CHANGELOG.md), and is mirrored in the retired-key entry comment and registry.ts. The prior review graded the original arguable; the rewrite made it false in a published contract artefact.

③ Settled ground — re-measured, undisturbed. handleAutomationRequest gates listRunsPage (2551) and answers deps.error(RUNS_LIST_UNSUPPORTED_MESSAGE, 501) naming the member (2674); buildApiError derives code from standardErrorCodeForHttpStatus and HttpStatusErrorCodeMap[501] is NOT_IMPLEMENTED; the 403 run-read gate at 1627 (isRunStateRead: GET with parts.length === 2) runs first. Over-read is exact: listHistory(flowName, limit + 1) (engine 4709), comparison ordered.length exceeds limit after byId dedupe, status filter and sort (4805); neither listHistory implementation clamps its argument (in-memory slices to limit; DB-backed fetches max(limit*4, 200) then slices to limit), so limit + 1 at 100 is honoured. limit intact: .min(1).max(100).default(20) unchanged, base literal hasMore: false at 2625 gone (control: the list-flows literal at 1722 remains). Tombstone: retiredKey() is z.never({ error }).optional().describe('[REMOVED] …'), so the input type is never and presence throws the prescription; the zod tests pin prescription-not-generic, every spelling including empty, absence, and limit with its default. Registry: D3 fields byte-equal to the entry (415/565/3612/2740 chars), key under the 18: block with the /packages sibling as control. Truncation table pins fewer, one-short, exactly, one-more, far-more, 1-of-many and 1-of-1; the spy pins 21; listRuns is the runs projection. Query-validation: the #7300 refusal cases are superseded on the same inputs (200, no cursor reaches the service), hasMore relayed both ways, 501 pinned by status and member name, limit rows unchanged but for the dropped cursor: undefined.

② Semver level

minor across @objectstack/spec, @objectstack/runtime, @objectstack/service-automation, @objectstack/client — all four at 17.4.0 and none private, so the package set is complete and minor yields the 17.5.0 the prescription and the flows.mdx row name. The level meets the floor: scripts/check-changeset-no-major.mjs exists at head, Clause-② is declared, and breaking-ness is carried by the **BREAKING** banner plus registered automation-runs-cursor-retired, whose id resolves in registry.ts step18 and matches the landed sibling 17667-packages-query-contract.md in form. Sentence by sentence against head and base: the pre-change declaration (zod 527), boundary validation (runtime 2620), contract slot (contract 646), SDK emitters, the absent nextCursor writer (zero non-comment hits at base with export-service.ts:111 as control), the FROM/TO parse example, the tombstone rationale, limit unchanged with .default(20), the over-read, the 501, the #7300 reversal, the ?status= qualification and the SDK paragraph are all true. One sentence is false: L22–23 "an optional, non-unique startedAt" (see ② (b)). One is loose but not misleading: "cannot smuggle it past the retired schema" (L49) — the pin is that the SDK never appends the key; the route ignores rather than refuses a raw ?cursor=, which L105–113 states plainly.

③ Boundary flags

  • Head reviewed is 810829e078f85808b06b77a5308bf0cc5cd1a53b, read at start and re-read at the end; it did not move.
  • FAIL ground: the word "optional" in the ordering-key sentence, in four places — RUNS_LIST_CURSOR_REMOVED (packages/spec/src/api/automation-api.zod.ts), .changeset/19365-automation-runs-cursor-hasmore.md L22–23, the retired-key entry comment and its registry.ts mirror — with content/docs/references/api/automation-api.mdx:527 regenerated from the corrected prescription.
  • Prose glitch, not false: the D3 reason reads "…no residue to accept. ADR-0049 / The SDK half is part of the retirement…" — an orphaned "ADR-0049 / " fragment left where the SDK paragraph was spliced in. It ships into the upgrade guide; fix in the same stroke.
  • ListRunsResponseSchema.nextCursor remains declared and never emitted; within the ruling's letter for door ①, now commented in place, and the SDK's door-③ emitter at index.ts:6640 is the hand-off the author names for the later act.
  • The ?status= window residual is pre-existing, stated in six consumer-facing places, and closing it is a RunStore contract change outside the ruling.
  • Docs: the flows.mdx row is true against the head (bounds, default, refusal, ignored-not-refused, prior repeat-400, computed hasMore, ?status= clause, 501 NOT_IMPLEMENTED, grant); both changed automation-api.mdx rows are byte-equal to the schema strings.
  • NOT MEASURED (empty node_modules): gate union, tests, typecheck, check:generated. CI at last poll: 23 success, 4 skipped, 0 failure, 6 in progress (Lint & Repo Gates, Test Core 1/3/5/6, Type Check · workspace). On the discarded-run story: credible on mechanism — three of the four named gates are packages/spec scripts that read dist, and the teardown self-test pins a rev with a designed unreachable-in-shallow refusal — but the 112 · 111 · 1 reading itself is the author's, not mine.

Implemented-by: claude/issue-19365-automation-runs-hasmore
Reviewed-by: session_01UDXER3sdqfeVYpEWZs5mZx

VERDICT: FAIL — What must change for a re-review to pass: make the ordering-key sentence exactly true by replacing "an optional, non-unique startedAt" with wording that matches the head (for example "a required but non-unique startedAt timestamp — nothing ever minted a resume point from it") in RUNS_LIST_CURSOR_REMOVED, the changeset L22–23, the retired-key entry comment and its registry.ts mirror, then regenerate content/docs/references/api/automation-api.mdx so row 527 stays byte-equal to the prescription; and in the same stroke remove the orphaned "ADR-0049 / " fragment from the D3 reason in both the entry and registry.ts. The prior FAIL ground is closed and is not re-raised; everything else in ① through ④ was measured and holds.


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>

os-warren commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 113/113 CONTRACT_REVIEW_TIER

N = assistant request rows in the isolated reviewer's transcript, every one stamped RETIRED-TIER by the harness; at the coarser grain it reads the same — 28 distinct requestIds, 28 at tier. ⛔ Not self-reported: the reviewer cannot read its own tier and was told to answer NOT READABLE if asked.

Head-sha: 99ad620c544e1f3f4e0976596a6e8a7e4620d70e

⚠️ Posted here rather than on card #19365 because that card answers 404; this PR is the surviving home for its records. Round 2's record was re-anchored here at 5756785503 for the same reason.

Read from the PR API at start and again at the end: unchanged. It is a merge commit (1be868a5 + origin/main eec56c37); the merge-base with origin/main is eec56c37, so git diff eec56c37..99ad620c (20 files, +1216/−93) is exactly the PR and is what every reading below was taken against. The merge itself touches one PR file, packages/spec/src/migrations/registry.ts (+100 lines: another entry arriving from main), and the mirror still equals the entry byte for byte (below), so the text merge was exact. Nothing was built, run or written in the shared checkout; every reading is git show/git grep at the sha, the REST proxy, or a byte-compare in scratch.

① Derived judgments

Accept/reject set — the request schema. ListRunsRequestSchema.cursor: z.string().optional().describe('Cursor for pagination')retiredKey(RUNS_LIST_CURSOR_REMOVED), which packages/spec/src/shared/retired-key.ts defines as z.never({ error: () => guidance }).optional().describe('[REMOVED] ' + guidance). Any value throws the prescription; absence parses clean with no cursor materialised; limit keeps .min(1).max(100).default(20) and status is untouched — all pinned in automation-api.zod.test.ts (prescription regex, every spelling including '', absence, default 20, 0/101 throw). The parent AutomationFlowPathParamsSchema is a plain z.object under lazySchema and the schema is built by .extend(), so a bare deletion would have been the ADR-0104 silent strip — the tombstone is the right form and matches the landed /packages sibling (package-api.zod.ts, also "in @objectstack/spec 17.5.0"). Right.

Accept/reject set — the wire. (a) ?cursor= in every spelling → 200, no cursor in the options object; at base a single string was validated and forwarded (parseStringParam('cursor', query.cursor), base automation.ts) and repeated/structured/numeric answered 400 VALIDATION_FAILED with details.fields[{ field: 'cursor', code: 'invalid_type' }] (base test, the #7300 describe). I checked that nothing in front of this door refuses on its own: refuseRepeatedQueryParams / refuseUnknownQueryParams are applied per route in packages/rest/src/rest-server.ts (packages, data, export, search… rows), the automation domain reaches handleAutomationRequest through HttpDispatcher (hono adapter and dispatcher-plugin.ts both construct it), query-multiplicity.ts says the rule serves only rest-server read points and the /packages dispatcher domain, and the route-ledger row GET /automation/:name/runs declares no parameter set. So "200, key ignored" is true of the wire, not just the dispatcher harness. Right, and it is the ruling's letter C carried through. (b) hasMore: base deps.success({ runs, hasMore: false }) (the literal; the list-flows literal in the listFlows branch remains as the control) → const { runs, hasMore } = await automationService.listRunsPage(name, options). In AutomationEngine.listRunsPage the paused arm is store.list() (no window), the ring is filtered by flow (no window), the history arm is listHistory(flowName, limit + 1), then byId dedupe, status filter, startedAt sort, { runs: ordered.slice(0, limit), hasMore: ordered.length > limit }. Both listHistory implementations honour any limit (in-memory: filter/sort/.slice(0, limit); DB-backed: find with Math.max(limit * 4, 200) then .slice(0, limit)), so 101 at the wire maximum is honoured; RunStore.listHistory?(flowName, limit) in engine.ts is outside the diff. Unfiltered the answer is exact: if the history holds ≥ limit + 1 rows the arm alone contributes limit + 1 distinct ids, so true is true; otherwise the merged set is complete. Under status the filter runs after the over-read, so false is a statement about the scanned window — and that qualification is now on the RunListResult.hasMore docblock, the listRunsPage docblock, the response .describe(), the generated reference, the changeset, the D3 entry and flows.mdx. run-list-truncation.test.ts pins fewer / one-short / exactly / one-more / far-more / 1-of-many / 1-of-1, the probe row not leaking, the spy at 21, default 20, flow isolation, retention-is-not-more, listRuns as the runs projection, and the status residual. Right. (c) 501: deps.error(RUNS_LIST_UNSUPPORTED_MESSAGE, 501)HttpDispatcher.errorapiErrorResponse({ httpStatus: 501 })error-envelope.ts code: input.code ?? promoted ?? standardErrorCodeForHttpStatus(httpStatus)errors.zod.ts 501: 'NOT_IMPLEMENTED', so the flows.mdx row's 501 NOT_IMPLEMENTED is true. isRunStateRead (GET, parts[1] === 'runs', length 2 or 3) gates at the top of the handler, ahead of the branch, pinned in the permission-gate test (403, listRunsPage never called). Radius for "no composition reaches the 501": implements IAutomationService lights on engine.ts only; a definition-shaped grep for listRuns over every non-test .ts/.tsx/.js/.mjs in the tree lights on the client (×2), the spec contract map and the engine, nothing else. Right, and it is "absence must be loud" rather than the domain's 404.

Exported surface. + RunListResult (interface) on @objectstack/spec/contracts, mirrored by exactly one row each in api-surface/contracts.json and export-origins/contracts.json; authorable-surface/api.json api/ListRunsRequest:cursor [RETIRED]; IAutomationService.listRuns options lose cursor?: string; + listRunsPage?. @objectstack/client: the three option types and three params.set('cursor', …) emitters are gone; at head the only cursor code left in index.ts is listRevisions and ai.conversations.list — the lit control — plus docblocks. The absence pin in client.test.ts is failure-capable (base emitters fired on if (options?.cursor)) and covers all three surfaces through the same fetchMock.

Registry and generated mirrors. Extracted the D3 object from registry.ts and the entry file, evaluated both in node: id 30 / surface 415 / replacement 565 / reason 3612 / acceptanceCriteria 2746 chars, EQUAL on all five. api/ListRunsRequest:cursor sits in the 18: [ block of RETIRED_KEYS_BY_MAJOR with the entry's comment carried. automation-api.mdx cursor row equals '[REMOVED] ' + the reconstructed 873-char prescription byte for byte, and its hasMore row equals the new .describe() string byte for byte. spec-changes.json and docs/protocol-upgrade-guide.md carry neither this id nor the landed sibling's (control), so their non-movement is consistent. No tracked generated JSON still says "Cursor for pagination" for ListRunsRequest (whole-tree grep: the only survivor is the ListFlowsRequest row, a different door).

The three cross-surface facts the previous rounds failed on, re-measured. Version: prescription "@objectstack/spec 17.5.0"; generated reference the same; flows.mdx "17.5"; SDK docblocks now "@objectstack/spec 17.5.0" ×3; changeset minor on four packages, all 17.4.0, all in the one fixed group of .changeset/config.json, no pending major changeset on any of them (every .changeset/*.md front matter scanned) → 17.5.0; PROTOCOL_VERSION = '17.0.0'. Consistent. "protocol 18" survives only at a pre-existing unrelated docblock in index.ts (capability vocabulary), outside the diff. Ordering key: ExecutionLogEntry.startedAt: string (the type the comparator runs over), ExecutionLogSchema.startedAt: z.string().datetime() with no .optional(), sys_automation_run.started_at required: true (that object lives in packages/services/service-automation/src/sys-automation-run.object.ts, not in spec), and the only index on it, { fields: ['flow_name', 'started_at'] }, is not unique (no unique anywhere in the file). "Required but non-unique" holds on every surface. Clamp vs refuse: parseIntegerParam with bounds read off ListRunsRequestSchema.shape.limit.unwrap() throws min_value/max_value400 VALIDATION_FAILED (the #8054 rows pin 0, 101, 1000); the engine reads options?.limit ?? 20 with no clamp; lit control messaging-service.ts Math.min(Math.max(opts.limit ?? 50, 1), 200). The server refuses — but the rewritten sentence overshoots the code on the one surface that carries it. The automation.runs.list docblock now reads "It is bounded to 1..100 and a value outside that range is REFUSED with 400 VALIDATION_FAILED, never clamped", and its own emitter, four lines below, is if (options?.limit) params.set('limit', String(options.limit)): a caller passing { limit: 0 } — a value outside 1..100 — never sends the key, the server applies its default window, and the caller receives 200 with 20 runs. The two sibling surfaces (automation.listRuns, ScopedEnvironmentClient.automation.listRuns) use opts?.limit != null, do send limit=0, and are refused. So the sentence is false at exactly one input, the three surfaces disagree on that input, and the docblock promising refusal is the one on the surface that silently substitutes. Negative and fractional values are truthy, are sent, and are refused, so the sentence fails only at 0. Born in this round's rewrite; the earlier "clamped" was wrong the other way.

Changed without needing to. Nothing of substance; the [#7300] comment block is kept with its second bullet marked as reversed history. One looseness, not a ground: the SDK docblock's "read by nothing on the server" versus the prescription's "VALIDATED at the boundary and then read by nothing" — the boundary did read query.cursor to validate it; the intended sense (no server logic consumed the value) is true and the form is copied from the #6361 docblock.

② Semver grade vs. the changeset's declaration

minor on @objectstack/spec, @objectstack/runtime, @objectstack/service-automation, @objectstack/client — all four at 17.4.0, none private, one fixed group, so the set is complete and yields the 17.5.0 every other surface names. The grade is the right one for this repo: scripts/check-changeset-no-major.mjs exists at head, Clause-② is declared, and breaking-ness is carried by the **BREAKING** banner plus <!-- adr-0087: registered automation-runs-cursor-retired -->, whose id resolves in registry.ts step 18 — the same form as the landed .changeset/17667-packages-query-contract.md, present at head. registered is the honest disposition (a retiredKey() prescription is a migration prescription; HTTP-only, so no D2 conversion; no default, so no residue stage). Sentence by sentence against base and head, the prose is true: the pre-change declaration and description; "validated at the boundary, forwarded into the service contract, appended by the SDK, and read by no implementation" (base route parseStringParam('cursor', …), base contract slot cursor?: string, base client emitters, base engine's only cursor hits are unrelated locals); "No emit site has ever written the response half nextCursor" — true at base and head across the whole tracked tree (the automation hits are the two schema declarations and comments; export-service.ts, the storage adapters and packages.ts are the lit control; the checkout is shallow, so "ever" is measured at the two endpoints, as the sibling changeset's identical claim was); the FROM/TO parse example (default 20 applied at base, the prescription thrown at head); the tombstone rationale; "Writing the key is now a tsc error" (the house phrasing from retired-key.ts; the input type is never | undefined, and { cursor: 'x' } fails as claimed); the SDK FROM/TO and the TS2353 claim (true of an object literal, which the example is); limit unchanged with .default(20); "the HTTP boundary enforces the declared 1..100 range read off the schema itself"; the over-read and the unchanged listHistory signature; the 501 naming the member; the #7300 reversal with the details.fields[] form it replaces; the ?status= qualification; "recorded the removal in its docblock". "cannot smuggle it past the retired schema" is loose (the SDK drops the key; the route ignores a raw one) but the next paragraph says so plainly. The CHANGELOG reader is sent, by that last sentence, to the SDK docblocks — and the automation.runs.list docblock is where the false sentence in ① lives.

③ Boundary flags

  • ListRunsResponseSchema.nextCursor stays declared, never emitted — correctly out of scope: letter A not taken, an absent optional key promises nothing, now commented in place.
  • GET /automation list-flows literal hasMore: false plus ListFlowsRequestSchema's unread limit .default(50) / cursor — the fourth door the round-2 record flagged. It is now filed: open issue [finding] GET /automation (list flows) is a FOURTH door of #19365's class — ListFlowsRequestSchema declares limit with an APPLIED .default(50) and a cursor, and the handler reads neither #19528 (2026-09-21T06:55Z, "[finding] GET /automation (list flows) is a FOURTH door of [finding] three sibling list doors carry the same declared-but-never-read limit/cursor shape that #17667 is retiring on /packages — export jobs, AI conversations and automation runs #19365's class…"), read off the repo's open-issue listing. Correctly not widened into this PR; the ListFlowsRequest cursor row in automation-api.mdx belongs to that card.
  • ?status= window residual — pre-existing (listHistory has no status slot), stated on seven consumer-facing surfaces, closing it is a RunStore contract change. Correctly out of scope.
  • Newest-ness under a raised retention capObjectStoreSuspendedRunStore.listHistory fetches max(limit*4, 200) rows with no order clause and sorts in memory; "Returns the newest limit runs" (new SDK docblock) and the store's own "newest terminal run-history rows" hold under the default cap of 100 and could not be defended for a deployment holding more than ~404 rows per flow. Pre-existing, not this PR's; pointer only.
  • File-surface deviations (contract member, two runtime doubles, flows.mdx, packages/client) — each forced by the ruling's text or by a row that became false. Correct.
  • Declared narrowing (i): the 112-family gate union not re-run at this head. What stands in: on this head Lint & Repo Gates ran 173 of 184 steps to green before step 🔗 Broken links detected in documentation #181, Build Core ("Verify build outputs") is green, and 29 of 32 named checks are green. The two tail gates the failure left unmeasured — Unquoted workflow step names… and Duration-shaped spec keys carry their unit… — are NOT MEASURED; the diff touches no workflow file and adds only runs/hasMore/listRunsPage as new keys, so it is unlikely to trip them, but that is a reading of the diff, not of the gates.
  • Declared narrowing (ii): the client suite and typecheck not re-run, on the argument that this round's index.ts change is comments only. Tested: git diff 81f11e5..1be868a -- packages/client/src/index.ts is 24 changed lines and every one is a * TSDoc continuation; the argument holds for the branch's own change. It does not cover the merge commit, which brought five main commits — for those, CI on this head is the only reading (Test Core (1/6) and (5/6) still in progress at my last poll; the other lanes green).
  • The failing check, read first-hand. Lint & Repo Gates on this head: step 🔗 Broken links detected in documentation #181 Issue citations this change adds resolve on the board exits 2 — "22 citation(s) THIS CHANGE ADDS do not resolve … 34 judged across 8 files, 22 allocated-but-absent, 12 resolve". All 22 sites are #19365 on comment-prose lines (client index.ts ×3, automation.ts ×3, engine.ts ×4, automation-api.zod.ts ×5, automation-service.ts ×5, the retired-key entry ×1, registry.ts ×1), classed "minted (≤ 19536) and absent from the board". I re-asked the gate's question myself over every added non-test source line: eight distinct numbers, #126 #204 #7300 #7359 #8054 #17667 → 200, #19365 → 404, #6361 → 404. #6361 is not among the gate's 22: on added lines it sits only inside string literals of the D3 entry and its registry mirror, a test-file comment and the changeset — all outside the gate's comment-prose / non-deferred surfaces — so the seat's Lint comment overstated which numbers the gate flags (its own superset probe was honest about that). Control that makes this a reading about the board: the identical citations were green on 81f11e5 (Lint & Repo Gates success, completed 06:15:04Z) and red on 1be868a and here. Verdict on ownership: not this PR's defect — the number that stopped resolving is the card the PR is Part of, and the gate's own remedy text forbids guessing a replacement. It is nonetheless a required check that stays red until the maintainer either restores the card or has the 22 sites annotated the way the gate itself allows ("keep the number and say in prose that it no longer resolves and what the live record is").
  • Governance and size. None of the 20 paths is under docs/adr/**, AGENTS.md, CLAUDE.md, .claude/**, skills/** or content/docs/releases/**; Part of #19365 with no closing keyword. The (AGENTS.md route-ownership rule 5) relabel is accurate: rule 5 of "Route & surface ownership" is the closed-query-set rule, and it says existing routes convert per lane, which is exactly this route's state.
  • Console channel. At the pinned .objectui-sha 87af769e (read with git show in the sibling checkout, not its HEAD), FlowRunsPage.tsx sends { limit: 20 } and FlowRunsPanel.tsx sends ?limit=25; the one cursor hit in FlowRunsPage.tsx is className="cursor-pointer". "Every channel this repo ships" holds. Docs sweep radius: content/, docs/, skills/, apps/, examples/, packages/cli, every README.md/*.md outside CHANGELOGs and release pages — the files naming this door that also say "cursor" are flows.mdx and automation-api.mdx (the needles) and two index pages whose hits are WebSocket/collaboration Cursor* types.
  • Not measured here: no gate, test or typecheck was executed (read-only shared checkout). CI at last poll on this head: 29 success, 2 skipped, 1 failure (Lint & Repo Gates, above), 2 in progress (Test Core (1/6), Test Core (5/6)) — NOT MEASURED, not a pass.
  • Same-stroke, not grounds: tighten "read by nothing on the server" to the prescription's "validated at the boundary and read by nothing".

Ground 1packages/client/src/index.ts, the TSDoc on the list arrow inside the runs namespace of ObjectStackClient.automation (automation.runs.list): the sentence "a value outside that range is REFUSED with 400 VALIDATION_FAILED, never clamped" is false of that method for { limit: 0 }, because its emitter is if (options?.limit) params.set('limit', …), so the key is never sent and the server answers 200 with the default window of 20, while the two sibling listRuns surfaces (opts?.limit != null) send it and are refused — to pass, either make that emitter options?.limit != null (the three surfaces then agree and the sentence becomes true; note it as a one-line SDK behaviour change in the changeset) or narrow the sentence to say that 0 is dropped client-side and only a sent out-of-range value is refused. Everything else in ① through ③ was measured and holds.

VERDICT: FAIL

Implemented-by: claude/issue-19365-automation-runs-hasmore (mode:subagent)
Reviewed-by: session_01UDXER3sdqfeVYpEWZs5mZx


交接 —— 载体已剥;并附本席对自己一条已发布评论的更正

Ground 1 本席第一手复测,成立。 三个 run-list 发射点的判据在同一个文件里就不一致:

packages/client/src/index.ts
  :5547  ← 那句话:「a value outside that range is REFUSED … never clamped」
  :5554  if (options?.limit) params.set('limit', …)        ← 真值判据 ⇒ limit: 0 根本不发
  :5633  if (opts?.limit != null) params.set('limit', …)   ← 发得出去 ⇒ 被 400 拒

⇒ 携带那句承诺的,恰恰是会静默替换的那一个:{ limit: 0 } 在它这里被丢掉,服务端套用默认窗口 20,调用方拿到 200。负数与小数是真值、发得出去、确实被拒,所以这句话只在 0 这一个输入上假。⭐ 三轮,三次假话,三次都诞生于一次改写——上一轮把「clamped」改掉时,把它改到了另一边的界外。

⚠️ 对本席评论 5757007310 的更正

那条评论把引用门的阻塞物写成两个号(#6361#19365)。只有 #19365 被这道门判。 门的投影是 comment-prose:代码(含字符串字面量)会被抹掉,测试文件与 changeset 另有排除。本席逐条复看本 PR 新增行上的四处 #6361:

.changeset/19365-…md                         ← changeset,排除
packages/client/src/client.test.ts           ← 测试文件注释,排除
…/entries/semantic/18.automation-runs-cursor-retired.ts  ← 在字符串字面量里,被抹掉
packages/spec/src/migrations/registry.ts                 ← 同上

我当时跑的是超集探针(所有新增行上的所有号),并在文中写明了那是我自己重问的问题;但我没有把「门实际判哪一个」与「哪些号 404」分开讲,结果把阻塞面说宽了。⇒ 阻塞物是一个号,不是两个,而 main 里那 47 处 #6361 不受这道门影响(它只判新增的引用,且多数在字符串里)。

⭐ 而且这道门自己写明了第三条出路,本席先前说没有

scripts/check-issue-citations.mjsREMEDY 逐字:

⛔ Do NOT guess a replacement number … Either name a target that resolves, or keep the number and say IN PROSE that it no longer resolves and what the live record is.

⇒ 「保留号码 + 在散文里说明它已不可解析、现行记录在哪」是门自己认可的路,它既不伪造出处、也不删除出处——正是我当初反对删改的理由。我先前对维护者说只有「恢复」或「裁决重指」两条路,那是错的,已当面更正。

⛔ 本席擅自去做那 22 处标注:一旦卡被恢复,那些标注就会变成发布在源码里的假话——正是本卡要终结的缺陷类。是否永久、要不要走这条路,是维护者的字。

欠改(照复核的 To pass,⛔ 不加码)

automation.runs.list 的发射点改成 options?.limit != null(三个面就一致了,那句话随之为真;changeset 里记一行 SDK 行为变化),把句子收窄成「0 在客户端被丢弃,只有发得出去的越界值才被拒」。⛔ 二选一,不要两个都做,也不要顺手改别的。

同笔顺手(复核明写不是判据):把「read by nothing on the server」收紧成处方的「validated at the boundary and read by nothing」。

⛔ 卡态与 assignee 不动(卡已 404,无可动)。⛔ PR 仍 draft、未入队、未挂 auto-merge。引用门那条红与本判决无关,仍等维护者的字。

⚠️ Redacted 2026-09-21T18:2xZ by the seat. Model identifier VALUES in this comment were replaced by the names 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

…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>

Copy link
Copy Markdown
Collaborator Author

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

os-warren commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 100/100 CONTRACT_REVIEW_TIER

N = assistant request rows in the isolated reviewer's transcript, every one stamped RETIRED-TIER; 22 distinct requestIds, 22 at tier. ⛔ Not self-reported.

Head-sha: ae87f1fde8e6748bdb29666c07171537a8f352c4

Read from the PR API at start and again at the end: unchanged, still draft, no auto-merge, zero formal reviews. Merge-base with origin/main is eec56c37 (= the PR's declared base), so git diff eec56c37..ae87f1fd (20 files, +1225/−93) is the PR; the round-4 commit is 99ad620c..ae87f1fd (16 files, +57/−48). Nothing was built, run or written in either shared checkout; every code reading is git show/git grep at the sha, and the two executions below ran on byte-identical copies of the head's files extracted into scratch (git hash-object of the probe's index.ts = the head blob fc23fd96).

① Derived judgments

The round-3 FAIL ground, re-taken by execution rather than reading. I ran the head's packages/client/src/index.ts (unmodified; imports stubbed) through new ObjectStackClient({ baseUrl, fetch: mock }) and called all three run-list surfaces with limit ∈ {omitted, undefined, null, 0, -0, NaN, '', -5, 1.5, 101, 20, 100, 1, Infinity, false}, reading the URL each emitter handed the mock. automation.runs.list (guard if (options?.limit)): every falsy input — undefined, null, 0, -0, NaN, '', false — produced no query string; -5, 1.5, 101, Infinity and the in-range values were sent as ?limit=<String(v)>. automation.listRuns and environment().automation.listRuns (guard opts?.limit != null): undefined/null not sent; 0, -0 sent as ?limit=0; NaN as ?limit=NaN; '' as ?limit=; everything else as above. Lit control for the instrument: the same run fires ?limit=0 on the two != null surfaces and ?limit=-5 on all three, so "no query string" is a reading, not a dead mock. Radius: the three run-list surfaces only; listRevisions (truthy guard; index.ts carries params.set('limit' ×11 across four guard spellings) and ai.conversations.list were deliberately not exercised. Then I executed the head's parseIntegerParam (packages/runtime/src/query-param.ts, extracted verbatim, validationFailure stubbed to a throwing error) with the door's bounds {min: 1, max: 100} on every string the emitters sent: -5min_value, 1.5invalid_number, 101max_value, Infinityinvalid_number, NaNinvalid_number, 0min_value, falseinvalid_number; ''undefined (default window); 1/20/100→accepted. validationFailure maps to VALIDATION_FAILED_STATUS = 400 (packages/types/src/validation-failure.ts) with code VALIDATION_FAILED, and the head's automation-runs-query-validation.test.ts pins exactly these inputs (1.5, Infinityinvalid_number; 0, -5, 101, 1000min_value/max_value, status 400, service never called); Test Core is green on this head. Against that table the rewritten docblock on the list arrow in the runs namespace of ObjectStackClient.automation is true clause by clause: "Omit limit to take the server's window (20)" (no key → parseIntegerParam returns undefined → engine options?.limit ?? 20); "a value this method SENDS that falls outside [1..100] is REFUSED with 400 VALIDATION_FAILED, never clamped" (every sent out-of-range value refused; no Math.min/Math.max on the path — the engine reads ?? 20 only); "0 and NaN are the exception … dropped rather than refused: the guard below is truthy, so a falsy limit never leaves the client and the server answers its DEFAULT window" (the emitter four lines below is literally if (options?.limit); -0 is 0; no key → 20); "-5, 1.5 and 101 are truthy, are sent, and are refused" (measured on both ends); "The two listRuns surfaces guard on != null and do send 0" (literal guards at the alias and at ScopedEnvironmentClient.automation.listRuns; ?limit=0 measured). The same-stroke change "validated at the boundary and read by nothing beyond it" is true of the base: eec56c37 automation.ts built cursor: parseStringParam('cursor', query.cursor), the base contract slot was cursor?: string, and the base engine's only non-comment cursor identifiers are unrelated locals. The pre-existing inbox-door sentence "read by nothing on the server" at the notifications docblock is untouched, as declared. Ground closed; no sentence added or rewritten this round is false of the head.

Guard provenance (claim 2). if (options?.limit) params.set('limit', String(options.limit)) is present at the merge-base (eec56c37:packages/client/src/index.ts line 5542) and in the PR's whole-diff of that file it is a context line — only the params.set('cursor', …) line below it is removed. The two != null guards are likewise base lines 5616/8095. So the divergence is pre-existing and the PR touches none of the three guards; option (b) was the honest scope call (③).

Accept/reject set and public surface vs merge-base — unchanged from the rounds that measured them, re-read at this head. ListRunsRequestSchema.cursor is retiredKey(RUNS_LIST_CURSOR_REMOVED) (z.never({ error }).optional().describe('[REMOVED] …')); limit keeps .int().min(1).max(100).default(20); the prescription still says @objectstack/spec 17.5.0 and "required but non-unique startedAt", and content/docs/references/api/automation-api.mdx row 527 carries it byte-for-byte, row 573 the hasMore .describe() byte-for-byte. The wire: handleAutomationRequest's parts[1] === 'runs' GET branch reads limit with bounds off ListRunsRequestSchema.shape.limit.unwrap(), reads status off ExecutionStatus.options, builds no cursor, calls listRunsPage, and answers deps.error(RUNS_LIST_UNSUPPORTED_MESSAGE, 501) when the member is absent (errors.zod.ts 501: 'NOT_IMPLEMENTED'); isRunStateRead gates ahead of it. Engine: listRunsPage is limit = options?.limit ?? 20, listHistory(flowName, limit + 1), byId merge, status filter, startedAt sort, { runs: ordered.slice(0, limit), hasMore: ordered.length > limit }; listRuns returns .runs of the same call. Exported: + RunListResult, + IAutomationService.listRunsPage?, listRuns options lose cursor; the only params.set('cursor' left in the client are listRevisions and ai.conversations.list (the lit control). The D3 entry automation-runs-cursor-retired and its registry.ts mirror are equal on all 100 non-comment lines (the only diff is the closing }; vs },), and api/ListRunsRequest:cursor sits in the 18: block. The merge commit 99ad620c brought +100 lines into registry.ts from main (another entry) and nothing else into a PR file.

Card re-pointing (claim 3), measured with a lit control. git grep '#19365' over the whole tracked tree at head: 0. Bare 19365 not preceded by # and not the changeset hash 19365b7: 0. Lit control by the same instrument over the same files: #19543 lights 41 sites across 16 files, and per file the #19543 count at head equals the #19365 count at 99ad620c (1·1·3·3·1·4·3·1·4·4·2·5·5·1·1·2). Radius: every tracked path at the head sha; two known targets deliberately outside it still carry the old number — the changeset FILENAME .changeset/19365-automation-runs-cursor-hasmore.md (a path, not content; no gate parses changeset filenames, and changeset version deletes it) and the PR body (twice, each time stating the 404). Mechanical check of every round-4 hunk: 38 of 39 hunks differ from their - side by exactly #19365#19543; the one exception is the automation.runs.list docblock rewrite judged above. Nothing else was re-pointed. #6361: 13 occurrences at 99ad620c, 13 at head, per file identical (changeset 1, client.test.ts 3, index.ts 1, semantic entry 1, registry.ts 7); it still answers 404, as does #19365; controls #17667, #19528, #19543, #19545 answer 200.

Executable surface (claim 4). Every changed line this round in index.ts (29), automation.ts (6), engine.ts (8), automation-api.zod.ts (10), automation-service.ts (10) and the retired-key entry (2) is a // or * comment line; the semantic entry and registry.ts each change one string-literal continuation inside the D3 reason field (data, not logic); tests change five describe/it titles and comments; the changeset title row and the flows.mdx row change one number each. The implementer's sentence is exact for the five files it names and its own report names the two string literals; nothing executable moved.

Nothing left inconsistent across the surfaces carrying one fact. Version: prescription, generated reference, flows.mdx (17.5), SDK ×3, changeset (minor ×4 at 17.4.0 → 17.5.0) agree. Out-of-range limit: the wire surfaces say refused 1..100; the SDK docblock says the same for what it sends and now names the two inputs it never sends. Ordering key: "required but non-unique" on every surface. ?status= qualification: on every consumer surface.

② Semver grade vs. the changeset's declaration

minor on @objectstack/spec, @objectstack/runtime, @objectstack/service-automation, @objectstack/client — all four at 17.4.0, none private, all in the single fixed group of .changeset/config.json, so the set is complete and yields the 17.5.0 every prose surface names. Against what the diff does to the accept set — a request key narrowed to never with a thrown prescription, a slot removed from IAutomationService.listRuns's options, a strictness reversal on ?cursor=a&cursor=b, plus a widening (listRunsPage?, RunListResult) — strict semver would grade major; this repo's rule does not, and it lives in scripts/check-changeset-no-major.mjs (launch window: breaking ships as minor, major is refused, and a Clause-②: yes PR must grade at least one moved package minor or above) with the breaking-ness carriers being the **BREAKING** banner and the ADR-0087 marker that scripts/check-adr-0087-registration.mjs requires — both present (<!-- adr-0087: registered automation-runs-cursor-retired -->, id resolving in registry.ts step 18; registered is the only honest category for a retiredKey() prescription, HTTP-only so no D2, no default so no residue). Clause-②: yes on the PR body and the changeset agree; the declaration carries no (narrowing) arm although the diff narrows, which AGENTS.md's Post-Task step 3 permits ("at most one arm") and which every gate that reads the level or the declaration accepts (Check Changeset success at this head; Lint & Repo Gates success; check-changeset-fixed satisfied). The level is right for this repo and would be too low only under the strict-semver rule the same script says returns at GA. The changeset prose is sentence-by-sentence true of base and head as measured in ① and in the two prior records; the one sentence it sends the CHANGELOG reader to — "recorded the removal in its docblock" — now lands on a docblock that is true.

③ Boundary flags

  • Deviation SCOPE, option (b) over (a) — correct: the guard is pre-existing at eec56c37, untouched by the PR, and changing it is an SDK behaviour change the ruling (retire cursor; limit stays as it is) does not cover. But the residual is a reproducible pre-existing defect — the same logical call { limit: 0 } answers 200 with 20 rows through automation.runs.list and 400 VALIDATION_FAILED through the two listRuns surfaces — and Prime Directive chore: version packages #10 says file it; it is recorded only in the round-4 os-dev-report comment, not in the PR body's acceptance notes and not as a card. The seat should file it (the implementer's family-level evidence, 11 limit emitters under four guard spellings, is the right scope) or add it to the acceptance notes in the same stroke.
  • Deviation MEASUREMENT (the broken grep -o | grep -v probe) — re-taken here with a different instrument (git grep -P with look-arounds), zero bare occurrences, control lit at 41; the corrected reading stands.
  • Deviation PROCESS (temporary probe file in packages/client/src) — the PR's 20-file list at head carries no such file.
  • Round-3 declared narrowings (gate union and client suite not re-run) — closed at this head by CI: 31 success / 4 skipped / 0 failure / 0 in progress across 35 check names, all seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard) success; Lint & Repo Gates includes step 🔗 Broken links detected in documentation #181, the citation gate, now green on the re-pointed diff (.changeset/** and test files are deferred surfaces for it; .changeset filenames are not read by it).
  • Out-of-scope findings carried forwardListRunsResponseSchema.nextCursor declared/never emitted (within the ruling's letter, commented in place); the ?status= window residual (pre-existing, stated on every consumer surface); the fourth door is filed as [finding] GET /automation (list flows) is a FOURTH door of #19365's class — ListFlowsRequestSchema declares limit with an APPLIED .default(50) and a cursor, and the handler reads neither #19528 (resolves); listRevisions and ai.conversations.list untouched (lit control above). All correctly left alone.
  • Card and PR body, outside the diff — the PR body keeps #19365 twice, each time saying it is 404, which is honest; the PR body and the card body both still cite the gating card #19361, which is 404 and rebuilt as [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 (open, resolves) — the card says it will be re-pointed and has not been, and #19364 in the PR body is 404 too. Not a gate input and not a tree surface; the seat's to re-point.
  • Closing keyword — line 1 is Part of #19543; the repo's own closingKeywordRe applied to the raw body binds nothing; Part-of PR must not also close its card and The card this PR closes must claim this branch are both success at this head. Part of is right: [finding] three sibling list doors declare limit/cursor and never read them, one reporting hasMore: false as a literal — REBUILD of #19365, which stopped resolving on 2026-09-21 #19543 is open and carries doors ② and ③, which this PR does not close.
  • Governance and size — none of the 20 paths is a governed surface; 1,318 changed lines; no content/docs/releases/ or CHANGELOG.md edit; .objectui-sha unchanged at 87af769e, where the Console sends { limit: 20 } (FlowRunsPage.tsx) and ?limit=25 (FlowRunsPanel.tsx) and no cursor.
  • Landing preconditions the seat owns, not grounds — the rebuilt card [finding] three sibling list doors declare limit/cursor and never read them, one reporting hasMore: false as a literal — REBUILD of #19365, which stopped resolving on 2026-09-21 #19543 has zero comments, so check-clause2-carriers --pair 19493 will report C2 "no claim comment" until a Claim: carrying Clause-②: yes is posted there, and C6 wants this head's sha in a ## Contract review record with the carrier lines the adopting seat fills.
  • Not measured here — no gate, test or typecheck was executed by this reviewer; the two executions above are the emitters and parseIntegerParam alone, on extracted copies with stubbed imports.

VERDICT: PASS

Implemented-by: claude/issue-19365-automation-runs-hasmore (mode:subagent)
Reviewed-by: session_01UDXER3sdqfeVYpEWZs5mZx


交接 —— PASS,双载体同笔已剥;落地前置三条逐条在案

这是第五轮。 前四轮:轮 1 达档 FAIL、轮 2 FAIL、轮 3 FAIL、轮 4 无 FAIL 但被板上事故挡住。三次 FAIL 都是一句假话,都诞生于一次改写

让这一轮不同的不是更小心,是换了验证方式。 前三轮都用读代码来验句子;这一轮 dev 与复核各自独立地执行了它——把三个发射点对十五个边界输入跑过真客户端,再把发出去的每一个字符串喂给真的 parseIntegerParam。复核的亮控也是执行出来的:同一次运行里 ?limit=0 在两个 != null 面上确实发出、?limit=-5 在三个面上都发出,所以「没有查询串」是读数不是死 mock。⇒ 本轮每一条子句都对应一行实测,没有一句是推出来的。

落地前置(references/contract-review.md 三条),逐条

条件 状态
达档条款②复核 PASS 在案 本记录,Served-tier: 100/100,所判 head ae87f1fde8e6
双载体已清 + --pair 机读 本笔剥标;剥前 --pair 19493 = exit 0
PR check 全绿 35 个名字,31 success / 4 skipped,非绿 0、在跑 0;七个必过上下文全 success

⚠️ 前置②今天差一点不成立,记在这里因为它是重建卡的次生缺口:卡 #19365 消失时,认领评论跟着没了,而入队闸门的声明腿只读认领评论的行首键--pair 当时 exit 4。本席已在 #19543 上补回认领(Clause-②: yes 一行抄自模板,⛔ 非凭记忆),才有现在的 0。⇒ 重建一张卡 = 恢复工作项 + 恢复它的协议载体,两件事。

复核点名归本席的三件,逐件处置

  1. { limit: 0 } 的跨面分歧要立卡 —— 已立,[finding] @objectstack/client's 11 limit emitters guard three different ways, so the same { limit: 0 } is silently dropped on some doors and refused with 400 on others — and { limit: null } is sent as the string null on six of them #19567(复核起跑时它还不存在)。本席自取的普查比 dev 报的更糟:11 个发射点、三种行为,另加一条 dev 没点到的——六个 !== undefined 守卫会把 String(null) 即字符串 "null" 发上线。
  2. 正文里 #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,本席同笔在正文与卡上改指。
  3. #19364 也是 404 —— ⛔ 不改指:它是一张已合并的 PR,不是卡,没有等价目标;按门禁自己的 REMEDY 保留号码并在散文里写明。

⛔ 卡 #19543 保持 open:它带着门②门③,而本 PR 只关门①(首行 Part of,⛔ 非 Fixes)。

⚠️ Redacted 2026-09-21T18:2xZ by the seat. Model identifier VALUES in this comment were replaced by the names 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

@os-warren
os-warren marked this pull request as ready for review September 21, 2026 09:04

Copy link
Copy Markdown
Collaborator Author

Landing provenance — all three preconditions in case, PR is ready, and the enqueue is NOT done

domain:spec execution seat 2, session session_01UDXER3sdqfeVYpEWZs5mZx, 2026-09-21T09:05Z.

precondition evidence
at-tier clause-② review PASS on record comment 5757956654, Served-tier: 100/100 \CONTRACT_REVIEW_TIER`, head ae87f1f`
both carriers cleared, machine-read needs:contract-review stripped from this PR and from card #19543 in one stroke; check-clause2-carriers --pair 19493 = exit 0, and it now also reports 「a review of record names this head」
checks green 35 check names, 31 success / 4 skipped, 0 failure, 0 in progress; all seven required contexts success

mergeable_state: clean, head unchanged at ae87f1fde8e6, base main, no governed-surface path in the 20 files.

Done in this act: the PR is out of draft (ready_for_review).

NOT done: entering the merge queue. The call was refused by this session's permission layer, reason [Merge Without Review]. ⛔ This seat did not and will not route around that refusal — a refusal is a decision, and finding another door for the exact action that was refused is the one thing it forbids. The maintainer has been told.

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.

⚠️ Two things a later reader should not have to rediscover:


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

✅ Correction — this PR is NOT blocked any more. It is in the merge queue.

domain:spec execution seat 2, session session_01UDXER3sdqfeVYpEWZs5mZx, 2026-09-22T00:3xZ.

The maintainer re-granted the permission and the call was retried. ⛔ The earlier standing-down note on this PR — "auto_merge could not be enabled … the only remaining act is enabling auto-merge" — is now false and is retracted here.

⚠️ And the seat's first read-back of that retry was WRONG. Recording it, because it is the more useful half.

PUT .../ccr/auto_merge returned 200 {"enabled":true,"merge_method":"squash"}. The seat then read GET /pulls/{n} and saw auto_merge: null on all four, and was one step from reporting "returned 200 but stored nothing" — the known 「状态码不作数」 failure shape.

That reading was the wrong instrument. On a repository with a merge queue, the action does not populate the auto_merge attribute at all — it enqueues the PR. The repo's own channel table says so in as many words: 「问本仓 auto-merge 是否经队列,答案来自尝试动作,不来自属性字段」, and its criterion ② is the added_to_merge_queue timeline event. The seat read the field the table warns has no discriminating power, ⛔ not the event the table names.

The evidence, on two independent instruments:

  1. Timelineadded_to_merge_queue on all four, at 00:35:03 / 00:35:05 / 00:35:06 / 00:35:08Z, the exact moment of the four PUTs.
  2. git, zero quota — the queue branches exist on origin and are chained, each built on the previous one's result:
gh-readonly-queue/main/pr-19602-1c16889a…  -> dc9e29bb
gh-readonly-queue/main/pr-19609-dc9e29bb…  -> 71f94e29
gh-readonly-queue/main/pr-19610-71f94e29…  -> 157c62f9
gh-readonly-queue/main/pr-19493-157c62f9…  -> 85265e6f

⇒ queue order #19602#19609#19610#19493, each tested against the cumulative result of the ones ahead of it. That is the merge queue doing its job, and it is ⛔ not a bypass: the seat did not merge, did not enqueue by hand, and submitted no approving review.

What happens next

Each PR merges as its queue branch goes green. ⚠️ A queue branch can still fail — it tests a combination that never existed before — and if it does, the PR is ejected and that is this seat's to diagnose, ⛔ not a re-enqueue on reflex.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants