Skip to content

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

Draft
os-warren wants to merge 14 commits into
mainfrom
claude/issue-19365-automation-runs-hasmore
Draft

os-warren wants to merge 14 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 #19365

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, 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 #19361, 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) 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 61170fa385da18a04e3fed75e8c65cbff801e008packageMentionDocs.

Which tree this was computed on

This run read content/docs from bca8acc2f35dc081f67f12595a3a681634013885 — the merge of head 99ad620c544e1f3f4e0976596a6e8a7e4620d70e into base 61170fa385da18a04e3fed75e8c65cbff801e008, 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 bca8acc2f35dc081f67f12595a3a681634013885 && git checkout bca8acc2f35dc081f67f12595a3a681634013885
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 61170fa385da18a04e3fed75e8c65cbff801e008 99ad620c544e1f3f4e0976596a6e8a7e4620d70e && git checkout -B drift-repro 61170fa385da18a04e3fed75e8c65cbff801e008 && git merge --no-ff 99ad620c544e1f3f4e0976596a6e8a7e4620d70e

node scripts/docs-audit/affected-docs.mjs --json 61170fa385da18a04e3fed75e8c65cbff801e008

⚠️ 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 61170fa385da18a04e3fed75e8c65cbff801e008 → 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>

Copy link
Copy Markdown
Collaborator Author

⚠️ Re-anchor — this PR's round-2 contract-review record, re-posted here because the card it was written on is no longer reachable

domain:spec execution seat 2, session session_01UDXER3sdqfeVYpEWZs5mZx, 2026-09-21T07:14Z.

What happened, as measured, ⛔ not as inferred. The record below was first posted at 2026-09-21T06:53Z as comment 5756558349 on card #19365. As of this writing GET /repos/objectstack-ai/objectstack/issues/19365 answers 404, and so does GET /repos/objectstack-ai/objectstack/issues/comments/5756558349. Control that keeps this a reading: GET .../pulls/19493 answers 200 and comment 5755970189 (this PR's round-1 record, which was posted here rather than on the card) answers 200 — so the instrument reaches this repository's issue and comment routes; it is that card and its comments specifically that are gone.

This seat cannot see WHY. The account route (/users/...) is refused by this session's proxy for every login — control: GET /users/os-warren, this seat's own account, also answers 403 with the same 「sessions are bound to their configured repositories」 body. So any statement here about an account's state would be invented, and there is none.

⛔ Nothing below is rewritten, re-judged or re-run. It is the same record, verbatim, including its Served-tier: stamp and its verdict. The carriers were already stripped in the same stroke as the original posting and stay stripped.

⚠️ For anyone following the pointers: the record's references to 「card #19365」 and to its comment ids are unreachable for the same reason. The ruling that scoped this door is still quoted verbatim inside this PR's own diff — the D3 entry's reason field in packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts — which is a landed artefact rather than a recollection.


Contract review

Served-tier: 74/74 CONTRACT_REVIEW_TIER

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

Head-sha: 81f11e52dea4f74a10eee2abd2f19c734c5db7f8

Head read at start and at the end of this review from the PR API: unchanged. It is a merge commit (c7ff363 + origin/main c736eaa); the merge-base with origin/main is c736eaa, so git diff c736eaa..81f11e5 (20 files, +1212/−93) is exactly the PR and is what every reading below was taken against. Nothing was built, run or written in the shared checkout; mechanical currency is read off CI at this head (all seven required contexts success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance, Governed Surface Queue Guard), which is evidence that the generated artefacts are current and the pins pass, never that a sentence is true.

① Derived judgments

Accept/reject set — the request schema. ListRunsRequestSchema.cursor: z.string().optional()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 (a string, '', a number) now throws the prescription; absence parses clean with no cursor materialised; limit keeps .min(1).max(100).default(20) and status is untouched — all four pinned in automation-api.zod.test.ts. Right: letter C of the ruling, the tombstone form matches the landed /packages and notifications siblings, and the parent is a plain z.object via .extend() so a bare deletion would have been the ADR-0104 silent strip. authorable-surface/api.json carries api/ListRunsRequest:cursor [RETIRED]; authorable-defaults/api.json correctly still carries api/ListRunsRequest:limit = 20.

Accept/reject set — the wire. (a) ?cursor= in every spelling (single, empty, repeated, structured, numeric) → 200, key never built into the options object; before, a single string was forwarded and repeated/structured/numeric answered 400 VALIDATION_FAILED (base test file, the #7300 describe). Right within the ruling ("the packages/runtime cursor parsing removal is inside this card's surface now"), and the "no closed query set" claim holds: this door is served by the runtime domain handler, not packages/rest's refuseUnknownQueryParams, and the route-ledger row GET /automation/:name/runs declares no parameters. parseStringParam remains used (5 hits) so nothing dangles. (b) hasMore literal false → engine-computed. In AutomationEngine.listRunsPage the history arm is asked listHistory(flowName, limit + 1), and hasMore = ordered.length > limit is taken after byId dedupe, the status filter and the startedAt sort, with .slice(0, limit) keeping the window. Both RunStore.listHistory implementations honour limit + 1 (in-memory: filter/sort/.slice(0, limit); DB-backed: find with Math.max(limit * 4, 200) then .slice(0, limit)); neither clamps at 100. DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW = 100 equals the wire maximum, so at limit=100 the history arm alone can never overflow under the default cap — correct, since an evicted run is not "more", and pinned as its own case. The three-row boundary table (fewer / exactly / more), the probe-row non-leak, the 21 spy, the default-20 case and the listRuns-is-the-runs-half pin are all in run-list-truncation.test.ts as claimed (14 cases). The ?status= residual is real (listHistory has no status slot) and is stated on every surface a consumer meets. (c) 501 when the service lacks listRunsPage: deps.error(msg, 501)HttpDispatcher.errorapiErrorResponse({ httpStatus: 501 }), and errors.zod.ts maps 501: 'NOT_IMPLEMENTED', so the flows.mdx row's 501 NOT_IMPLEMENTED is true. The run-read gate isRunStateRead(parts, m) sits at the top of handleAutomationRequest (ahead of the branch by ~900 lines), so 403 still precedes the service probe. AutomationEngine is the only implements IAutomationService in the tree and gains the member, so no shipped composition reaches the 501. Right (Route & surface ownership rule 3).

Exported surface. + RunListResult (interface) on @objectstack/spec/contracts (barrel export * from './automation-service.js'; the root entry does not re-export contracts, so api-surface/root.json rightly did not move — IAutomationService is absent there too, the control); + IAutomationService.listRunsPage? optional; IAutomationService.listRuns options lose cursor?: string. api-surface/contracts.json and export-origins/contracts.json gain exactly RunListResult. @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 (a different domain) and ai.conversations.list (door ③) — the lit control — plus docblocks. Right, and necessary: with the schema typing the key never, a shipping SDK still sending it would have re-created the defect one layer down.

Registry and generated mirrors. api/ListRunsRequest:cursor sits in the 18: [ bucket of RETIRED_KEYS_BY_MAJOR (header at registry line 13084; the /packages pair is its neighbour). The D3 entry's four prose fields are byte-equal to their registry.ts mirror (415 / 565 / 3612 / 2740 chars), surface carries no backticks, and the reason now ends …recorded the removal in its docblock. ADR-0049 / ADR-0087, #19365. — the orphan fragment the previous review flagged is gone. The generated automation-api.mdx cursor row equals [REMOVED] + the reconstructed 873-char prescription byte for byte, and the hasMore row equals the new .describe() byte for byte; the row at line 467 (ListFlowsRequest) is a different door. spec-changes.json and docs/protocol-upgrade-guide.md are release-time (the landed sibling's id is absent from both), so their non-movement is consistent.

The rewritten ordering-key sentence (last round's FAIL ground) is now true. startedAt is required on every layer the sort touches: ExecutionLogEntry.startedAt: string (engine.ts:1036, the type the comparator runs over), ExecutionLogSchema.startedAt: z.string().datetime() with no .optional() (execution.zod.ts:399), sys_automation_run.started_at … required: true; and the only index on it, { fields: ['flow_name', 'started_at'] }, is not unique. Lit control for the zero: git grep 'startedAt?:' -- 'packages/**' at head lights on exactly packages/core/src/utils/migration-journal.ts:267, so the instrument sees an optional startedAt where one exists.

What the diff changed that it did not have to. Nothing of substance; the [#7300] comment block is kept with its second bullet explicitly marked as reversed history, which is honest rather than stale.

Cross-surface disagreement, one fact, two surfaces — the defect class this card is about.

Two lesser imprecisions, not grounds: the D3 acceptanceCriteria (and its registry mirror, shipping into the upgrade guide) cites "(ADR route-ownership rule 5)" — no ADR carries that rule; it is AGENTS.md "Route & surface ownership" rule 5, and the house citation form is "Route-ownership rule #N" (ADR-0062's amendment uses it); the assertion itself is true. The client docblock's "read by nothing on the server" is looser than the prescription's "VALIDATED at the boundary and then read by nothing" (the boundary did read it to validate); the intended sense — no server logic consumed the value — is true.

② 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, so the set is complete and yields the 17.5.0 the prescription and flows.mdx name. The grade is the right one for this repo: scripts/check-changeset-no-major.mjs refuses major (the Check Changeset job is green 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 17667-packages-query-contract.md. registered is the correct disposition: a retiredKey() prescription is a migration prescription, the key is a wire surface a shipped client sent, and no D2 conversion applies (HTTP-only, nothing authored or persisted). Sentence by sentence against base and head, the changeset's prose is true: the pre-change declaration and its .describe(), "validated at the boundary, forwarded into the service contract, appended by the SDK, read by no implementation" (base automation.ts parseStringParam('cursor', …), base contract slot, base client emitters, base engine never touching options.cursor), no nextCursor writer at base (zero non-comment hits in runtime/service-automation; export-service.ts:111 as control), "required but non-unique startedAt", the FROM/TO parse and SDK examples (TS2353 for an object literal), limit unchanged with .default(20), the over-read mechanism and unchanged listHistory signature, the 501, the #7300 reversal, the ?status= qualification, and "recorded the removal in its docblock". That last sentence is where the CHANGELOG reader is sent — to the SDK docblocks that carry the two statements in ① that are not true of the head.

③ Boundary flags

  • ListRunsResponseSchema.nextCursor stays declared, never emitted — correctly out of scope: the ruling names only the request half for door ① (the "request and response halves together" language is door ②'s), an absent optional key violates nothing, and it is now commented in place. Hand-off to the door ②/③ act as the author says.
  • GET /automation (list flows) literal hasMore: false — measured true: the handler calls listFlows() with no arguments and answers { flows: names, total: names.length, hasMore: false }, so nothing is withheld. But the implementer stopped one line short and should have flagged the rest: ListFlowsRequestSchema declares limit: z.number().int().min(1).max(100).default(50) and cursor: z.string().optional(), the handler reads neither, no non-test parse site of ListFlowsRequestSchema exists in packages/runtime or packages/rest, and the client's automation.flows.list() takes no options. That is the identical declared-but-never-read limit/cursor shape with a materialised default — the /packages .default(50) trap exactly — on a fourth door the census did not name, class (b) by the card's own definition. Not this PR's to fix (the ruling scoped door ①), but Prime Directive chore: version packages #10 says file it or record it on the card, and neither happened. The same generated reference this PR regenerates still advertises that row (automation-api.mdx:467).
  • ?status= window residual — correctly out of scope (a RunStore contract change), true of the engine, and now stated at every consumer-facing surface: RunListResult.hasMore and listRunsPage docblocks, the response .describe(), the generated reference, the changeset, the D3 entry, flows.mdx.
  • File-surface deviations (contracts/automation-service.ts, the two runtime test doubles, flows.mdx) — each required by the ruling's own text or by a row that became false; packages/client came in on the seat's scope ruling and was the right call (measured above).
  • index.ts:6640 ai.conversations.list cursor — door ③, ruled and assigned; :3241/:3244 listRevisions — different domain, unmeasured, pointer only. Both correctly left alone.
  • ObjectStoreSuspendedRunStore.listHistory fetching max(limit*4, 200) without an order clause — pre-existing; with the default cap of 100 the over-read is unaffected. Not re-diagnosed.
  • The Console channel. At the pinned .objectui-sha 87af769e (read in the sibling repo at that sha, not at its checkout HEAD 98178b2), FlowRunsPage.tsx:448 sends { limit: 20 } and FlowRunsPanel.tsx:182 sends ?limit=25; no cursor. "Every channel this repo ships" holds.
  • Governance and size. None of the 20 paths is under docs/adr/**, docs/NORTH-STAR.md, .claude/**, skills/**, AGENTS.md or CLAUDE.md; 1,305 changed lines. Part of #19365 with no closing keyword (gate green) is right, since doors ② and ③ remain open on the card. No content/docs/releases/ or CHANGELOG.md was edited.
  • Not measured here: no gate, test or typecheck was executed in this session (shared checkout, no writes); the author's 112/111/1 reading is theirs, and CI at head is the reading I cite.
  • Same-stroke fixes that are not verdict grounds: relabel "(ADR route-ownership rule 5)" in the D3 acceptanceCriteria (entry file + regenerate the registry mirror) to the AGENTS.md rule it actually names.

VERDICT: FAIL — Ground 1: packages/client/src/index.ts, the TSDoc on the list arrow of the runs namespace inside ObjectStackClient.automation (automation.runs.list), says the server's window is "clamped to 1..100" when the door refuses an out-of-range limit with 400 VALIDATION_FAILED (parseIntegerParam bounds, pinned by the #8054 cases) and nothing on this door clamps — a sentence copied from the notifications door, where a clamp exists. Ground 2: the same docblock, the docblock on the listRuns alias in ObjectStackClient.automation, and the docblock on listRuns in ScopedEnvironmentClient.automation (all in packages/client/src/index.ts) say cursor "was removed in protocol 18 (#19365)" when the removal ships in @objectstack/spec 17.5.0 under protocol 17 (PROTOCOL_VERSION = '17.0.0', lockstep with the package major) — the version every other surface (prescription, generated reference, flows.mdx, changeset, registry comment) states — so the published SDK disagrees with the published spec about the one fact a reader of a retirement note needs. To pass: replace "clamped to 1..100" with wording that says the range is refused (e.g. "1..100, refused outside it"), and replace "protocol 18" with "@objectstack/spec 17.5.0" (or "17.5") in all three docblocks; everything else in ① through ③ was measured and holds.

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


交接 —— 双载体同笔已剥,卡态与 assignee 不动

两条依据本席都第一手复测过,都成立。 ⛔ 不是转述。

依据一(clamp) — ⭐ 先记一条方法:我按复核逐字引的 clamped to 1..100 直接 grep,零命中;换成按形状搜 clamped,它在 packages/client/src/index.ts:5543,因为句子跨行折了(… (20, clamped to / 1..100); raise it …)。逐字引一条被换行切开的散文会给出假零 —— 这与上一轮那处 ADR 断裂是同一课:按形状找,别按坐标找。机制侧:packages/runtime/src/query-param.ts:169/172 逐字 throw invalidQueryParam(param, 'min_value' …) / 'max_value';门上 automation.ts:2655ListRunsRequestSchema.shape.limit 自己的 min/max 传进去;引擎 engine.ts:4632options?.limit ?? 20,无 clamp。亮控点着:同仓 messaging-service.ts:541Math.min(Math.max(opts.limit ?? 50, 1), 200) 是真 clamp,而同一句话的出处 index.ts:6461(收件箱门,50, clamped to 1..200)在那里为真 —— 句子是从一扇真 clamp 的门抄到一扇 refuse 的门上的。

依据二(protocol 18) — 三处逐字在案:index.ts:5540 · :5619 · :8104。对照读数:packages/spec/src/kernel/protocol-version.ts:18 export const PROTOCOL_VERSION = '17.0.0';处方 automation-api.zod.ts:522 逐字 @objectstack/spec 17.5.0。⇒ 已发布的 SDK 与已发布的 spec 在「哪一版拿掉了它」这一件事上互相矛盾,而这恰是退役说明唯一要答的事。

独立性:Implemented-by: 是分支(子代理),Reviewed-by: 是本席会话 —— mode:subagent 的席内审是设计,⛔ 不是 SELF-REVIEW。复核子代理是隔离起的:只喂卡与 PR 本体、⛔ 未喂派发令、⛔ 未喂本席上一轮的任何结论,暂存限 pr-19493/

欠改(照复核的 To pass,⛔ 不加码):三处 docblock 的 protocol 18@objectstack/spec 17.5.0;clamped to 1..100 → 说明「范围外是拒收」。同笔顺手:D3 acceptanceCriteria 里的 (ADR route-ownership rule 5) 改成它真正引的 AGENTS.md「Route & surface ownership」rule 5 并重生成 registry 镜像 —— 复核明写这不是判据,但它与本卡同属「出处对不上」这一类。

③ 里那扇第四门本席另行立卡,⛔ 不扩本 PR:ListFlowsRequestSchemalimit .default(50) + cursor,而 automation.ts:1728-1731listFlows() 不带参数、hasMore: false 写死 —— 与本卡三扇门同形,而本卡正文的普查(「三扇兄弟门」)没点到它。

⛔ 本席不改 pm:dispatched、不改 assignee、不动 PR 的 draft 态。


Generated by Claude Code

…sal truthfully

Two sentences copied from the inbox door, where both are true, into the runs
door, where neither is:

- 'clamped to 1..100' — nothing on this door clamps. parseIntegerParam throws
  min_value/max_value (query-param.ts:168-173) on the bounds the door reads off
  ListRunsRequestSchema.shape.limit, and the engine is a bare '?? 20'. The
  window is REFUSED outside 1..100, not clamped. Lit control: the real clamp
  in this repo is messaging-service.ts:541.
- 'removed in protocol 18' — PROTOCOL_VERSION is '17.0.0' and the prescription
  publishes '@objectstack/spec 17.5.0'. The SDK was the one surface a CHANGELOG
  reader lands on, disagreeing with the spec about the single fact a retirement
  note exists to state. All three docblocks now carry the published version.

Same stroke: the D3 acceptanceCriteria cited '(ADR route-ownership rule 5)';
no ADR carries that rule. It is AGENTS.md 'Route & surface ownership' rule 5.
Registry mirror regenerated with the generator.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

🔴 Lint & Repo Gates — the diff did not change; the BOARD did. ⛔ No fix pushed, and here is why one would be worse than the red.

domain:spec execution seat 2, session session_01UDXER3sdqfeVYpEWZs5mZx, 2026-09-21T07:35Z. Head 1be868a5d13e6da7dd6dee15ff9f394b25a32415.

What is failing, named from the jobs API steps[] rather than from log proximity

Job Lint & Repo Gates (106247158115) stops at step #181 of 184, Issue citations this change adds resolve on the boardpnpm check:issue-citations && node scripts/check-issue-citations.mjs. 173 steps ran and passed before it; the job's own tail reporter states the consequence plainly: 2 gates NEVER RAN and are therefore NOT MEASURED, ⛔ not passed — Unquoted workflow step names do not silently truncate at " #" and Duration-shaped spec keys carry their unit in the key name.

Rest of CI on this head at the time of writing: 34 check names, Lint & Repo Gates the only non-green, 6 still running.

The gate is right, and it is not this diff's doing

I re-ran the gate's own question first-hand: every distinct issue number this PR cites on an added line, one board read each.

#126   200      #4127  200      #7300  200      #8054   200
#204   200      #6361  404 ⛔    #7359  200      #17667  200
                                              #19365  404 ⛔

The control that makes this a reading about the board rather than about the diff: this same job, on the previous head 81f11e5 — the same citations, byte for byte — completed success at 2026-09-21T06:01:18Z. Seven of the nine numbers still resolve now, so the instrument is not dark. Between that green run and this red one, two numbers stopped resolving, and nothing in the diff touched them.

Both are casualties of the same board-side event that also removed card #19365 itself (GET /issues/19365 → 404), for which this PR's round-2 contract-review record had to be re-anchored here as comment 5756785503.

This comment asserts nothing about WHY. The account route /users/... is refused by this session's proxy for every login — control: /users/os-warren, this seat's own account, answers 403 with the same 「sessions are bound to their configured repositories」 body. So the cause is outside what any reading available here can see.

Why no fix is pushed

The only change that would turn this step green is to remove or re-point the two citations. Both are load-bearing provenance, and rewriting them would be the worse defect:

  • #6361 is the precedent this entire retirement rests on — the notifications cursor retirement, cited in the D3 entry's reason, in the changeset, and in the SDK docblock, as the reason the client half belongs in the same change. ⚠️ It is also not this PR's private problem: #6361 is cited 47 times across 20+ tracked files on origin/main today, among them packages/spec/src/api/protocol.zod.ts, packages/spec/src/contracts/notification-service.ts, three migration entries, packages/spec/spec-changes.json and two published CHANGELOGs. This gate only judges citations a change adds, so main stays green — but every future PR that touches one of those lines inherits this red.
  • #19365 is the card this PR is Part of. Re-pointing it would mean inventing a target.

⇒ Deleting either would trade a visible red for a silent falsehood on a published surface, which is precisely the defect class this card exists to close. The red stays until the board is answered for.

What blocks fixing it, and what is NOT being spent

Blocked on a decision that is not this seat's: whether the removed board objects are restored, or whether the citations are to be re-pointed and under what provenance. Raised to the maintainer at the time of writing.

The one re-run is deliberately NOT spent. A re-run re-reads the same board and would answer 404 twice; "flake" is not a root cause, and this failure is stable and explained. It stays available for a genuine one.

⛔ Nothing else changed by this comment: the PR stays draft, no label moved, no carrier re-hung, and the round-3 rework in flight is untouched.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

os-dev-report

{
 "issue": 19365,
 "issue_reachable": false,
 "status": "done",
 "round": "PM loop round 2 — contract-review remediation round 3",
 "branch": "claude/issue-19365-automation-runs-hasmore",
 "pr": "https://github.com/objectstack-ai/objectstack/pull/19493",
 "head_for_re_review": "1be868a5d13e6da7dd6dee15ff9f394b25a32415",
 "previous_head_that_FAILed": "81f11e52dea4f74a10eee2abd2f19c734c5db7f8",
 "session": "session_01UDXER3sdqfeVYpEWZs5mZx",
 "premise_still_valid": true,
 "ANSWER_TO_THE_CITATION_QUESTION_ASKED_FIRST": {
  "question": "Did I push a change that touches the #6361 or #19365 citations?",
  "answer": "NO — neither citation was removed, re-pointed or softened, and I did not touch a `#6361` line at all.",
  "the_precise_nuance_stated_rather_than_glossed": "My round-3 diff DID edit three lines that CONTAIN `(#19365)` — they are the three SDK docblocks — but only to replace the version phrase `protocol 18` with `@objectstack/spec 17.5.0`. The citation itself is byte-identical on all three and still points at 19365.",
  "measurement": "git diff 81f11e5..1be868a, filtered to lines carrying either number, returns exactly three removed/added pairs, each differing only in the version phrase. Citation counts in packages/client/src/index.ts are IDENTICAL at both heads: #19365 = 3, #6361 = 1. Nothing added, nothing dropped.",
  "where_my_6361_citations_came_from": "Round 2, not round 3 — four added lines across the changeset, the client test comment, and the D3 entry plus its registry mirror. They were green at 81f11e5, which is the control you cite.",
  "the_nine_numbers_confirmed": "The PR's added lines cite exactly nine distinct numbers, which matches your probe: #126, #204, #4127, #6361, #7300, #7359, #8054, #17667, #19365.",
  "⛔_what_I_have_not_done_and_will_not": "I have not removed, re-pointed or softened either citation, have not re-run the job, and will not. Greening a board-side 404 by editing a published citation is the exact defect class this card exists to close — a visible red turned into a silent falsehood."
 },
 "a_push_I_am_HOLDING_pending_your_answer": {
  "what": "A local merge commit 99ad620 (origin/main eec56c3 into my branch) plus the five commits it brings. It is committed in my worktree and NOT pushed.",
  "why_held": "Your instruction was to not push again until you answer if my change touched either citation. My reading is that it did not — the citations are preserved verbatim — but the honest description is 'edited lines that contain a citation', which is close enough to the line you drew that I am not going to decide it for you. The merge touches no citation (it is other people's work plus a registry regeneration) and pushing it would move the head and restart CI while the blocker sits with the maintainer, which is noise you did not ask for.",
  "cost_of_holding": "None that I can see. Say the word and I push it; say the opposite and I drop it.",
  "note": "The head I am reporting, 1be868a, is the PUSHED head and is what CI judged."
 },
 "ground_1_the_clamp_claim": {
  "verdict": "CONFIRMED false, and I reproduced your false-zero exactly.",
  "the_method_note_reproduced": "grep for the literal `clamped to 1..100` in packages/client/src/index.ts exits 1 — ZERO hits — because the sentence wraps across two comment lines. grep for the shape `clamped` finds it at :5543 immediately. Same lesson as the ADR splice last round: ⛔ never conclude 'absent' from one literal miss; re-derive from what the thing IS.",
  "readings_that_prove_the_door_REFUSES": [
   "packages/runtime/src/query-param.ts:168-173 — `throw invalidQueryParam(param, 'min_value', …)` and `'max_value'`. It throws; there is no Math.min/Math.max anywhere in it.",
   "packages/runtime/src/domains/automation.ts:2652-2658 — the door reads `ListRunsRequestSchema.shape.limit.unwrap()` and passes its `minValue`/`maxValue` straight into that throwing parser.",
   "packages/services/service-automation/src/engine.ts:4632 — `const limit = options?.limit ?? 20;` and no clamp downstream.",
   "The refusal is PINNED: the #8054 cases in automation-runs-query-validation.test.ts assert `?limit=0` and `?limit=101` answer 400 with `details.code === 'VALIDATION_FAILED'`.",
   "LIT CONTROL, and it lights: packages/services/service-messaging/src/messaging-service.ts:541 is `Math.min(Math.max(opts.limit ?? 50, 1), 200)` — a real clamp. The instrument sees one where one exists, so the zero on this door is a reading.",
   "Provenance of the false sentence confirmed: packages/client/src/index.ts:6465, the inbox door, says `50, clamped to 1..200` — where it is TRUE. I copied a true sentence onto a door with the opposite behaviour."
  ],
  "what_it_says_now": "«Omit `limit` to take the server's window (20). It is bounded to 1..100 and a value outside that range is REFUSED with `400 VALIDATION_FAILED`, never clamped — so raise it deliberately to see further back.»"
 },
 "ground_2_the_version_claim": {
  "verdict": "CONFIRMED false.",
  "readings": [
   "packages/spec/src/kernel/protocol-version.ts:18 — `export const PROTOCOL_VERSION = '17.0.0'`, with the file's own docblock saying it is kept in lockstep with the package major and asserted against package.json by protocol-version.test.ts. So 'protocol 18' names a protocol that does not exist.",
   "packages/spec/src/api/automation-api.zod.ts:522 — the prescription publishes `@objectstack/spec 17.5.0`, and the generated reference, flows.mdx, the changeset and the registry comment all say 17.5 too. The SDK was the single dissenting surface — and the one a CHANGELOG reader lands on, because my own changeset sends them there."
  ],
  "fixed_in_exactly_three_places": "packages/client/src/index.ts at the `automation.runs.list` docblock, the `automation.listRuns` alias docblock, and the `ScopedEnvironmentClient.automation.listRuns` docblock. All three now read `@objectstack/spec` 17.5.0.",
  "⛔_what_I_deliberately_did_NOT_touch": "packages/client/src/index.ts:1719 also contains the string 'protocol 18'. It is a PRE-EXISTING docblock about capability-vocabulary producers, is not in this PR's diff, and is a different subject. Verified against the diff before editing and left alone."
 },
 "same_stroke_citation_relabel_and_a_correction_to_the_instruction": {
  "done": "The D3 acceptanceCriteria's `(ADR route-ownership rule 5)` is relabelled, in the entry file, and the registry mirror was regenerated with `gen:migration-registry` — ⛔ never by hand.",
  "⭐_I_did_NOT_use_the_wording_you_suggested_and_here_is_why": "You proposed the house form is `Route-ownership rule #N`. I measured it before writing, because writing an unverified citation form is precisely the mistake that produced the last two FAILs. The measured form in this tree is `route-ownership rule N` — lowercase, no `#`, parenthesised: 4 occurrences of `route-ownership rule 1` and 3 of `route-ownership rule 3`, in packages/runtime/src/domains/data.ts:62, action-execution.ts:390 and action-execution-calldata-batch-retired.test.ts:210. ZERO occurrences of a `#`-prefixed or capitalised form. The only two hits for `ADR route-ownership rule 5` were my own entry and its mirror.",
  "what_I_wrote": "`(AGENTS.md route-ownership rule 5)` — the measured lowercase form, with the source named since the whole point of the fix was that the citation pointed at the wrong document.",
  "if_you_prefer_your_wording": "Say so and I will change it; I am flagging the divergence rather than silently following or silently ignoring."
 },
 "every_sentence_I_touched_read_back_against_the_code": "The brief demanded this explicitly, so here is each claim with the reading that proves it. «Returns the newest `limit` runs — a WINDOW, not a page» — engine.ts:4805 sorts startedAt descending and slices to `limit`. «removed in @objectstack/spec 17.5.0» — matches the prescription at automation-api.zod.ts:522, and PROTOCOL_VERSION is 17.0.0 so the old 'protocol 18' could not be right. «it was appended to the query string here and read by nothing on the server» — the three `params.set('cursor', …)` lines existed at base and the engine never read the option; both measured in round 2. «Omit `limit` to take the server's window (20)» — parseIntegerParam returns undefined for a falsy raw, and engine.ts:4632 is `?? 20`. «bounded to 1..100» — the schema's own `.min(1).max(100)`, which the door reads rather than restates. «REFUSED with 400 VALIDATION_FAILED, never clamped» — query-param.ts throws min_value/max_value, pinned by the #8054 cases, and no clamp exists on this path. «There is no continuation token — read `hasMore`» — nothing has ever emitted nextCursor. «widen `limit` (1..100, default 20)» on the other two docblocks — range and default both as above, and neither of those two ever claimed a clamp, so neither needed that half of the fix.",
 "verification": {
  "ci_at_the_reported_head": "35 distinct check names on 1be868a: 32 success, 2 skipped, 1 failure. The single failure is `Lint & Repo Gates`, which is the board-side citation 404 you own and are holding. Judged against the other 34, this head is green.",
  "spec_tests": "packages/spec migrations.test.ts + automation-api.zod.test.ts — exit 0, 188 tests pass. These are the two that cover what this round changed: the D3 entry (migrations.test.ts walks every semantic entry and asserts acceptanceCriteria is non-empty) and the tombstone prescription pin (which still matches after the rewording — worth checking, since rewriting a prescription can silently orphan the regex that pins it).",
  "generated_artefacts": "`check:generated` after the spec rebuild: all 15 up to date, both before and after the origin/main merge.",
  "registry_regeneration_was_a_real_check_not_a_formality": "The merge brought another agent's new semantic entry into registry.ts, a file both sides had touched. I snapshotted git's text merge, regenerated from the entries, and diffed: IDENTICAL. Both entries present, 227 semantic (up from 226). So the text merge happened to be exact — which is the disjoint-per-entry-file property working, and I verified it rather than assumed it."
 },
 "what_I_did_NOT_measure_this_round_and_why": {
  "the_112_family_gate_union": "NOT re-run at this head. Stated plainly rather than implied. Reasons: (1) the local derivation flagged STALE and the stale inputs were `scripts/pm/dispatch-gates.mjs` ITSELF — the tool that decides the family list — which I could not hand-wave, so I merged origin/main to get a real derivation, and that merge is the push I am holding; (2) the shared verify lock refused twice, see below. The last full local reading was 112 derived · 111 exit 0 · 1 NOT MEASURED · 0 unrun at 81f11e5. What replaces it at this head is CI, which ran the real gates on the real head and reached step 181 of 184 in `Lint & Repo Gates` before the board-side 404 — a stronger reading for those families than my local one, with the three steps after #181 NOT MEASURED.",
  "the_client_test_suite_and_typecheck": "NOT re-run at this head, and here is the narrowing DECLARED rather than hidden: this round's diff to packages/client/src/index.ts is COMMENTS ONLY. Proven mechanically — every added and removed line in `git diff 81f11e5..HEAD -- packages/client/src/index.ts`, after excluding diff headers, is a TSDoc `*` continuation line; the filter for any non-comment line returns nothing. No executable line moved, so the round-2 readings (client 49 files / 569 tests, typecheck OK at 0 ledger debt) stand for the executable content. CI re-ran the suite anyway.",
  "one_reading_that_was_NOT_a_failure": "`pnpm --filter @objectstack/client typecheck` returned `TS2307: Cannot find module '@objectstack/core/logger'`. That is the unbuilt-dependency PREREQUISITE class — a fresh worktree with an un-built closure — ⛔ not a docblock-induced type error, and ⛔ not evidence about my change. I did not report it as a failure and did not 'fix' it."
 },
 "shared_lock_contention": "The verify lock refused TWICE with `VERDICT queue-timeout (exit 99) · never acquired`, 9 minutes each, holders being other agents' test runs (`--filter @objectstack/driver-sql --filter @objectstack/cli test`, then `--filter @objectstack/runtime test`). ⛔ Exit 99 is NOT MEASURED — not a failure and not a pass. Per the discipline I spent the interval on lock-free work rather than re-queueing blindly, re-requested under the SAME slot name so the queue position carried, and when 18 minutes had bought no progress I narrowed the check instead of holding the box hostage — the narrowing being the comments-only proof above, declared here rather than absorbed. A later, smaller acquisition for the spec build did succeed (waited 3m51s, held 4m47s).",
 "mcp_calls": "0 — no MCP GitHub tool at any point across four 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 because the card is 404). ZERO label writes. ⛔ No PR-body edit, and specifically ⛔ no edit to the `Part of #19365` line: it is a dangling reference now, but whether that card returns and under what provenance is the maintainer's call, not mine. Pushes this round: 1 (81f11e5..1be868a). One further commit is held unpushed, see above.",
 "the_card_404_verified_first_hand": "I re-took your readings rather than relaying them. GET issues/19365 → 404 and GET issues/comments/5756558349 → 404; controls GET pulls/19493 → 200 and GET issues/comments/5756785503 (the re-anchored record) → 200. So the routes work and that card specifically is gone. ⛔ I asserted nothing about why and did not go looking. I read the re-anchored record on the PR and confirmed its two grounds match what I fixed. Noted for the record: my three earlier os-dev-report comments went to that card and are gone with it, so this comment is the only surviving report for rounds 1-3 — the D3 entry's `reason` field in the diff remains the durable copy of the ruling itself, as you said.",
 "open_questions": [
  {
   "question": "Push the held merge commit 99ad620, or drop it?",
   "options": [
    "Push it — the branch is 5 commits behind origin/main and a current base makes CI meaningful",
    "Hold until the citation blocker is resolved, to avoid moving the head while the maintainer decides"
   ],
   "recommendation": "Hold. The merge changes nothing about the two FAIL grounds, and moving the head now restarts CI against a gate that will 404 again for reasons neither of us can fix."
  },
  {
   "question": "Keep `(AGENTS.md route-ownership rule 5)` or switch to the `Route-ownership rule #5` form you suggested?",
   "options": [
    "Keep the measured lowercase form",
    "Use the suggested form"
   ],
   "recommendation": "Keep the measured form — 7 occurrences in-tree use it and 0 use the other; but it is your call and the change is one word."
  }
 ],
 "out_of_scope_findings": [
  "carried forward, unchanged: packages/client/src/index.ts:6640 (ai.conversations.list) is door ③'s; :3241/:3244 (listRevisions) is a different domain, unmeasured, pointer only; ListRunsResponseSchema.nextCursor stays declared and never emitted; the ?status= residual is pre-existing and stated in six consumer surfaces. ⛔ None touched.",
  "noted, not filed, and already yours: the seat's own re-review flagged a FOURTH sibling door — ListFlowsRequestSchema's `limit .default(50)` + `cursor` against a `listFlows()` that takes no arguments and hard-codes `hasMore: false`. The seat said it is filing that separately. ⛔ I did not widen into it. Recording it here only because the card that would have carried the cross-reference is 404."
 ],
 "deviations": [
  "MEASUREMENT (declared narrowing): the 112-family gate union and the client suite were not re-run at this head. Both are covered above with the reason and with what stands in their place. A declared narrowing is a deviation, not a silent gap, which is why it is here.",
  "INSTRUCTION (flagged, not silently overridden): I used the measured citation form `route-ownership rule 5` rather than the suggested `Route-ownership rule #5`. Reason and measurement given above; trivially reversible.",
  "PROCESS: one push this round, made as soon as the edits were read back, per the lesson from two rounds ago. The merge commit is held, per your citation instruction, and I am asking rather than deciding."
 ],
 "ci_status": "1be868a: 32 success / 2 skipped / 1 failure across 35 check names. The failure is `Lint & Repo Gates` stopping at step 181/184 on two board-side 404s (#6361, #19365) that this PR did not introduce and that were green on the previous head with the same lines. That blocker is above me and is with the maintainer; ⛔ I did not touch it, re-run it, or work around it."
}

Generated by Claude Code

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 claude-fable-5-1 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。引用门那条红与本判决无关,仍等维护者的字。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants