Skip to content

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

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

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

Conversation

@os-warren

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

Copy link
Copy Markdown
Collaborator

Part of #19543

Clause-②: yes

Door ① of three. GET /api/automation/:name/runs declared a pagination
parameter it never spent, and then reported — as a literal — that there was
nothing more to fetch. Both halves are addressed here.

The ruling, which is the maintainer's call and not this PR's

Comment 5754491070 on #19365 records decision batch #204 item 2,
⚠️ and neither that comment nor that card resolves any more — #19365 was removed from
the board on 2026-09-21 and GitHub cannot restore a number. The number is kept here
rather than re-pointed, because the comment was never on any other card and naming a
different one would be false. The live record is #19543, the rebuild, which carries
this ruling quoted verbatim together with what could not be recovered. The ruling's own
durable copy is in this diff: the reason field of the D3 entry in
packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts. letters
C · C · A per door, maintainer 「204 同意」 2026-09-21. For door ① the
ruling reads, verbatim:

cursor is retired from ListRunsRequestSchema; limit stays (it is read
end to end and the Console's flow-runs page sends it today); the engine
reports truncation to the route and hasMore is computed, never
hard-coded. A (a cursor protocol for a 100-row window) and B (retire cursor
and leave the lie) are ⛔ not taken.

⛔ Not re-adjudicated here. Letter A — building a cursor protocol — is
explicitly not taken, so no continuation token is minted and nextCursor stays
absent.

Why Part of and not a closing keyword. Doors ② (export jobs) and ③ (AI
conversations) are ruled but gated on a cloud-repo reading riding #19545 (the rebuild of #19361, which no longer resolves), and
the ruling has the seat execute them on that reading's return without
re-entering the decision box. A merge that shut the card would strand
two-thirds of the ruled work, so the card stays open and the seat re-labels it.
The gate scripts/check-partof-closing-keyword.mjs is the mechanical half of
that, and its RULE 3 is why no sentence here binds a closing keyword to a
number at all — not even one written to prevent an auto-close, which is the
exact incident that gate exists for.

The premise was re-measured, and one half of the card's body is false

Every reading below was re-taken on origin/main at 5e7d83c, not relayed.

claim reading
cursor declared, never read holds — ListRunsRequestSchema declared it; AutomationEngine.listRuns never looked at the option; no emit site writes nextCursor
hasMore hard-coded holds — automation.ts returned deps.success({ runs, hasMore: false }), a literal, beside merged.slice(0, limit)
limit declared, never read ⛔ FALSE — read end to end
.default(20) unique to the export door ⛔ FALSE — ListRunsRequestSchema carries it too

limit is read at the boundary (parseIntegerParam, with the 1..100 bounds
taken off the schema itself), forwarded to IAutomationService, and spent by
the engine as RunStore.listHistory's window. It is also pinned by live
enforcement in automation-runs-query-validation.test.ts. Retiring it would
have been a regression, not a narrowing
, and the ruling says the /packages
parent ruling 5651023067 does not transfer. Both corrections belong on the
card's thread, which is the census.

What "truncated" means at this seam

The tempting signal is runs.length === limit. It is wrong at exactly one
input, and that input is undetectable from the response: a flow holding
exactly limit runs produces a window byte-identical to one held by a flow
with ten thousand.
Reporting true for the first is as wrong as false for
the second.

Only one of the three sources listRuns merges was ever capped — the durable
history arm, because RunStore.listHistory(flowName, limit) takes the window
as an argument. The paused arm and the in-memory ring are read in full. So the
signal chosen is an over-read of exactly one row: the history arm is asked
for limit + 1, and the merged, filtered, ordered set is compared against
limit. Overflow means a run matched that this window does not carry. The
extra row is dropped by the same .slice(0, limit) that was always there, so
nothing on the wire widens.

⛔ RunStore.listHistory's signature is deliberately not redesigned:
over-reading is expressible in the limit it already takes, so the truncation
signal costs the store contract nothing.

Two things hasMore deliberately does not mean, both pinned:

  • ⛔ not "retention evicted older runs" — a run the per-flow cap discarded
    does not exist any more; it is not "more" and no limit brings it back.
  • ⛔ not "there is a next page" — nothing mints a cursor. The caller's
    remedy is a wider limit, up to the declared 100.

One honest residual, pre-existing and unchanged. Under ?status=, the
history arm's window is still the newest limit + 1 rows of any status,
because listHistory has no status slot and the filter is applied to what
comes back. A status-filtered hasMore: false therefore means "no further
match within the scanned window", not "no further match exists". Pushing the
filter down is a store-contract change; the engine's own comment already
recorded this for the listing itself, and it is called out in the new test's
docblock rather than papered over.

Behaviour changes on the wire

1. ?cursor=a&cursor=b answered 400 VALIDATION_FAILED; it now answers
200 with the key ignored.
This reverses a decision recorded under #7300,
which chose to validate the key rather than decide it — the reasoning being
that a future cursor implementation must not be the one to discover the type
was never enforced. The ruling decides it instead: there will be no cursor
implementation on this door, so a refusal would be validating a key the
contract no longer has. This route declares no closed query-parameter set, so
an unrecognised name has never been refused here on its own account. The old
refusal cases are superseded by cases asserting the opposite on the same
inputs — the shape #7359 and #8054 already used on this route's other two
parameters.

2. hasMore can now be true. A request whose window is shorter than the
matching run set receives true where it previously received false. A caller
that read false as "this is the whole history" was always wrong and is now
told so.

3. A service implementing no listRunsPage answers 501 naming the
member, never a 200 carrying a guessed hasMore. "Absence must be loud" —
falling through to the domain's 404 would leave a caller unable to tell "no
run listing is mounted here" from "no such flow". The 403 run-read grant runs
ahead of the service probe and is unaffected, which is what that gate's own
note already required.

Shape of the change

  • spec — cursor: retiredKey(RUNS_LIST_CURSOR_REMOVED). A tombstone, not a
    deletion: the request schema is not .strict(), so a bare deletion makes Zod
    silently strip whatever a generated client keeps sending — a clean parse and
    a parameter that never takes effect, which is this defect re-created one
    layer down (ADR-0104). The form is copied from the landed sibling
    (The /packages read doors' declared request schemas and their actual query reads diverge in BOTH directions — ?limit= and ?cursor= are declared and never read, ?type= is read and never declared #17667 / PR feat(spec): the /packages doors declare the query parameters they execute, and retire the two they never did #19364 — that PR number no longer resolves and has no rebuild, being a merged PR rather than a card; card The /packages read doors' declared request schemas and their actual query reads diverge in BOTH directions — ?limit= and ?cursor= are declared and never read, ?type= is read and never declared #17667 resolves and is the live record) rather than invented.
  • contract — new optional IAutomationService.listRunsPage returning the
    exported RunListResult ({ runs, hasMore }) — the shape
    IExportService.listExportJobs already uses, minus the cursor nothing mints.
    cursor leaves listRuns's options in the same stroke.
  • engine — listRunsPage holds the whole method; listRuns is its runs
    half. ⭐ One implementation, two projections, so there is no second
    merge/filter/sort to rot. This is also why ~120 existing listRuns call
    sites across service-automation, plugin-approvals, examples/ and
    packages/cli are untouched.
  • ADR-0087 — RETIRED_KEYS_BY_MAJOR[18] entry plus the D3 semantic entry
    automation-runs-cursor-retired. No D2 conversion: a conversion rewrites an
    authored source or a stored sys_metadata row, and this shape is HTTP-only.
    Registered at 18, not 17, per the sibling convention.
  • changeset — minor across the three published packages, carrying the
    ADR-0087 disposition registered automation-runs-cursor-retired.
  • docs — content/docs/automation/flows.mdx's REST route table advertised ?cursor on this
    route. That row is false once the key is retired, so it now states the retirement, that a
    request still carrying the key is ignored rather than refused, and that hasMore is
    computed with a wider ?limit as the remedy. Flagged by Docs Drift Check (5755158989); the
    other 10 pages it named document the DATA door's hasMore and are true as they stand, so none
    was edited. Written by the dispatching seat, not the implementer — the implementer's one body
    write was spent at create.
  • SDK — @objectstack/client declared cursor and appended ?cursor= on all three run-list
    surfaces (automation.runs.list, automation.listRuns, client.environment(id).automation.listRuns).
    Retiring the key in the schema alone would have left the one generated client this repo ships typing it
    string and sending it into a route that no longer reads it — the ADR-0104 silent strip the tombstone
    exists to prevent, one layer down. The option and the emitter are gone from all three, the URL pin is
    inverted into a three-surface absence pin, and '@objectstack/client': minor joins the changeset. Same
    call the repo made when 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 ecf56e791e37bf1f5cc187c6824b003de704f528 → packageMentionDocs.

Which tree this was computed on

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

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 25d7dbd776aca23229f4dfaa60e8646f20a31108 && git checkout 25d7dbd776aca23229f4dfaa60e8646f20a31108
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ecf56e791e37bf1f5cc187c6824b003de704f528 ae87f1fde8e6748bdb29666c07171537a8f352c4 && git checkout -B drift-repro ecf56e791e37bf1f5cc187c6824b003de704f528 && git merge --no-ff ae87f1fde8e6748bdb29666c07171537a8f352c4

node scripts/docs-audit/affected-docs.mjs --json ecf56e791e37bf1f5cc187c6824b003de704f528

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

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

lane conclusion
Type Check · source gates success
Type Check · consumer gates cancelled
Type Check · workspace cancelled
Type Check · debt ledger cancelled

The aggregator refused to report a pass over three lanes that were never measured. That is the gate being correct — cancelled is NOT MEASURED, and NOT MEASURED is ⛔ never a pass. It is also ⛔ never a red about the code.

Why they were cancelled. The branch head moved to 6506b7c6 and the PR object updated at 2026-09-21T03:57:25Z — the author's own next push, which cancels in-flight runs on the previous head by the workflows' concurrency group. ⇒ the failure belongs to a head that is no longer the tip.

The authoritative reading is the current head. 6506b7c6: 32 check names, 0 failures, 20 still running (latest run per name; superseded runs of the same name are not the reading).

⛔ Nothing was pushed for this and ⛔ no re-run was spent: there is no live failure to fix, and re-running a superseded head buys nothing. If TypeScript Type Check goes red on 6506b7c6 with its member lanes reading failure rather than cancelled, that is a real reading and this seat will root-cause it.

⚠️ 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>
…s; re-point the card

FAIL ground: 'a value outside that range is REFUSED' was false at exactly the
falsy inputs. automation.runs.list guards on truthiness, so { limit: 0 } and
NaN are dropped client-side and the server answers its default window; the two
listRuns surfaces guard on != null and do send them. Proven by executing all
three emitters against eight edge inputs, not by reading. Option (b): the
guard is pre-existing and unauthorized to change, so the sentence narrows to
what the code does rather than the code changing to fit the sentence.

Same stroke: 'read by nothing on the server' becomes 'validated at the
boundary and read by nothing beyond it', matching the prescription — the
boundary did read the key, to validate it.

Card rebuild: #19365 is permanently 404 and #19543 replaces it. All 41
citations in this diff re-pointed (39 hand-written, 2 regenerated into the
registry mirror). #6361 is untouched at 13 occurrences — still 404, not among
the gate's sites, and not reconstructable.

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

Copy link
Copy Markdown
Collaborator Author

os-dev-report

{
 "issue": 19543,
 "note_on_the_card": "#19365 is permanently 404 and #19543 replaces it. I verified both myself: GET issues/19543 -> 200, state open, title '[finding] three sibling list doors declare `limit`/`cursor` and never …' (the rebuild); GET issues/19365 -> 404. Also verified #19545 -> 200 (the rebuilt #19361). So the number I re-pointed to RESOLVES — I did not trade one dangling reference for another.",
 "status": "done",
 "round": "PM loop round 2 — contract-review remediation round 4",
 "branch": "claude/issue-19365-automation-runs-hasmore",
 "pr": "https://github.com/objectstack-ai/objectstack/pull/19493",
 "head_for_re_review": "ae87f1fde8e6748bdb29666c07171537a8f352c4",
 "previous_head_that_FAILed": "99ad620c544e1f3f4e0976596a6e8a7e4620d70e",
 "session": "session_01UDXER3sdqfeVYpEWZs5mZx",
 "premise_still_valid": true,
 "the_FAIL_ground": {
  "verdict": "CONFIRMED, and the sentence was mine again — third rewrite, third false sentence.",
  "option_taken": "(b) — narrowed the sentence. ⛔ NOT (a), and the reason is not timidity: the truthy guard `if (options?.limit)` at :5554 is PRE-EXISTING. I measured it at the merge-base (eec56c3) and it is there, and my diff touches no `options?.limit` line at all. So (a) would have been an unauthorised SDK behaviour change — the ruling covers retiring `cursor`, not `limit`'s client-side handling — made inside a round whose entire job is to stop introducing things. I broke a sentence; I fixed the sentence.",
  "⭐_I_PROVED_IT_BY_EXECUTION_THIS_TIME": "The failure mode for three rounds has been that I verify sentences by READING code. This round I ran all three emitters against eight edge inputs through the real client with a mocked fetch, and read the emitted URLs. Temporary probe, deleted after — it is not in the diff.",
  "the_measured_table": {
   "automation.runs.list (truthy guard)": "undefined, null, 0, NaN -> NO `?limit=` sent. -5, 1.5, 101, 20 -> sent.",
   "automation.listRuns (!= null)": "undefined, null -> not sent. 0, NaN, -5, 1.5, 101, 20 -> sent.",
   "ScopedEnvironmentClient.automation.listRuns (!= null)": "identical to the alias — 0 and NaN ARE sent.",
   "what_that_proves": "The sentence was false at exactly the FALSY-but-out-of-range inputs, which is `0` AND `NaN` — the review named `0`; `NaN` is the same class and I would have missed it by reasoning about `0` alone. Everything truthy is sent and refused, so the rest of the sentence held."
  },
  "what_it_says_now": "«Omit `limit` to take the server's window (20). The declared range is 1..100, and a value this method SENDS that falls outside it is REFUSED with `400 VALIDATION_FAILED`, never clamped … ⚠️ `0` and `NaN` are the exception, and they are dropped rather than refused: the guard below is truthy, so a falsy `limit` never leaves the client and the server answers its DEFAULT window instead. `-5`, `1.5` and `101` are truthy, are sent, and are refused. The two `listRuns` surfaces guard on `!= null` and do send `0`.»",
  "every_clause_maps_to_a_measured_row": "There is no clause in that paragraph that the probe did not exercise."
 },
 "the_lesson_I_am_carrying_out_of_this": "Three rounds, three false sentences, all born in a rewrite, and the through-line is sharper than 'be careful'. Round 2: I fixed an arguable word by asserting `optional` without reading the declaration — I trusted a defensive `??`. Round 3: I fixed `clamped` by moving the claim across the refuse/clamp boundary and landed just outside it on the other side. Round 4 is the correction to the method, not the sentence: ⭐ **when a sentence describes a guard, enumerate the guard's inputs and EXECUTE the sentence at each edge.** `0`, `NaN`, `''`, `null` and `undefined` are where a truthy guard and a `!= null` guard stop agreeing, and no amount of reading the line tells you which of them your prose just promised something about. The probe took four minutes and would have caught all three rounds.",
 "same_stroke_tidy_up": "«read by nothing on the server» is now «validated at the boundary and read by nothing beyond it», matching the prescription's own wording — the boundary DID read the key, to validate it. ⛔ I did not touch the identical phrase at :6472, which is the inbox door's own pre-existing sentence and true there.",
 "card_re_pointing": {
  "derived_myself_not_taken_from_the_breakdown": "As instructed. My derivation over the PR's 20 files: 41 occurrences of `#19365` across 17 files. Every one was `#19365`; a correctly-written bare-number probe (my first attempt was broken — `grep -o` emits only the match, so the `grep -v` filter never applied, and I redid it) found ZERO bare occurrences in file contents.",
  "reconciles_with_the_gate_count": "The gate's 22 is the comment-prose subset and my 41 is the whole set; the 19-item difference is exactly the categories the gate blanks — 15 in test files, 1 changeset, 1 flows.mdx row, and 2 string literals (the D3 `reason` and its registry mirror). Per-file my counts match the gate's breakdown exactly, with one explained divergence: the gate says registry.ts ×1 and I count 2, because registry.ts mirrors BOTH the retired-key comment (judged) AND the semantic entry's string literal (blanked).",
  "what_I_changed": "All 41 — 39 hand-written across 15 files, plus 2 regenerated into registry.ts with `gen:migration-registry`, ⛔ never by hand. Verified after: ZERO `#19365` remain in any file of the diff, and `#19543` now totals 41.",
  "⛔_6361_untouched": "Counted before and after: 13 occurrences, identical. Not among the gate's sites, still 404, and not reconstructable — left exactly as written.",
  "⛔_annotation_remedy_NOT_used": "A target that resolves now exists, so the gate's own text prefers naming it. I did not write any 'this number no longer resolves' prose.",
  "one_thing_I_did_NOT_rename_and_why": "`.changeset/19365-automation-runs-cursor-hasmore.md` keeps its FILENAME. The citation inside it is re-pointed; the filename is not a reference any reader or gate resolves, and renaming it is churn with no consumer. Flagging it so the number appearing in a path is not mistaken for a missed site.",
  "⛔_PR_body_untouched": "`Part of #19365` on line 1 is the seat's to edit, as stated. I did not touch the body."
 },
 "verification": {
  "the_citation_gate_went_GREEN": "⭐ `Lint & Repo Gates` on ae87f1f: completed/success. I polled it to convergence rather than reporting an `in_progress` as a pass — it sat in_progress for ten polls and turned green on the eleventh. So the re-pointing cleared the blocker on its own, as predicted, and ⛔ I did not touch or re-run it at any point.",
  "ci_overall": "33 distinct check names on ae87f1f at last read: 24 success, 4 skipped, 5 still in_progress (`Test Core` 1/3/5/6 and `Type Check · workspace`). ZERO failures. The in_progress five are NOT MEASURED, ⛔ not passes.",
  "tests": "All four affected packages under the lock, exit codes captured before any pipe, VERDICT command-exit 0: @objectstack/spec 509 files / 14891 passed + 1 todo, @objectstack/service-automation 141 / 1690, @objectstack/runtime 272 files / 3799 passed + 1 skipped, @objectstack/client 49 / 569.",
  "generated_artefacts": "`check:generated` after the spec rebuild: all 15 up to date.",
  "the_executable_surface_of_this_round": "Measured rather than asserted: per-file, the non-comment changed lines are 0 for EVERY production source file — client/src/index.ts, runtime/domains/automation.ts, service-automation/engine.ts, spec/contracts/automation-service.ts and spec/api/automation-api.zod.ts all show 0. The only non-comment changes anywhere are markdown rows (changeset title, flows.mdx row), five `describe`/`it` TITLE strings in tests, and two string literals in the migration entry and its mirror. I ran the full suites anyway."
 },
 "mcp_calls": "0 — no MCP GitHub tool at any point across six rounds. All GitHub reads and writes via the REST proxy with curl.",
 "api_writes": "1 this round — POST /repos/objectstack-ai/objectstack/issues/19493/comments (this report, on the PR). ZERO label writes. ⛔ No PR-body edit. Pushes this round: 1 (99ad620..ae87f1f), made as soon as the edits were verified.",
 "open_questions": [],
 "out_of_scope_findings": [
  "noted, not filed, and it is the finding the FAIL ground sits on: the three run-list emitters in packages/client/src/index.ts do not agree about falsy `limit`. `automation.runs.list` guards on truthiness and silently drops `0` and `NaN`; the two `listRuns` surfaces guard on `!= null` and send them. So the SAME logical call answers `200` with 20 runs through one surface and `400 VALIDATION_FAILED` through the other two, for the same input. That is a silent substitution — the card's own defect class — but it is PRE-EXISTING (present at merge-base eec56c3, untouched by this PR) and fixing it is an SDK behaviour change no ruling covers. It is now documented in place rather than left to be rediscovered. ⭐ Worth a card of its own: the divergence is repo-wide, not local — `grep params.set('limit'` finds 11 emitters in that file across FOUR different guard spellings (`truthy`, `!= null`, `!== undefined`, and the inbox door's truthy), so whoever takes it should take the family, not this one line. Who will hit it: the door ②/③ act, which edits the same file.",
  "carried forward, unchanged and untouched: index.ts:6640 (ai.conversations.list) is door ③'s; :3241/:3244 (listRevisions) is a different domain; ListRunsResponseSchema.nextCursor stays declared and never emitted; the ?status= residual is pre-existing and stated in six consumer surfaces; the fourth sibling door (ListFlowsRequestSchema) is the seat's to file."
 ],
 "deviations": [
  "SCOPE (chosen and reported): given the explicit either/or, I took (b) and ⛔ not (a). Reasoning above — the guard is pre-existing and changing it is an unauthorised behaviour change. The consequence is that a real pre-existing inconsistency stays open; it is filed above as an out-of-scope finding with the family-level evidence rather than silently absorbed.",
  "MEASUREMENT (reported): my first bare-number probe was broken (`grep -o` output defeats a following `grep -v`), which would have told me there were no bare occurrences whether or not that was true. I noticed, redid it correctly, and the corrected reading is what I acted on. Reporting it because a broken instrument that happens to give the right answer is exactly what I was criticised for last round.",
  "PROCESS: a temporary probe file was written into packages/client/src and deleted after the reading; it is not in the diff and I verified its absence."
 ],
 "ci_status": "ae87f1f: 24 success / 4 skipped / 0 failure / 5 in_progress across 33 check names. `Lint & Repo Gates` — the blocker for the last two heads — is completed/success. The five in_progress are NOT MEASURED."
}

Generated by Claude Code

os-warren commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 100/100 CONTRACT_REVIEW_TIER

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

Head-sha: ae87f1fde8e6748bdb29666c07171537a8f352c4

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

① Derived judgments

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

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

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

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

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

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

② Semver grade vs. the changeset's declaration

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

③ Boundary flags

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

VERDICT: PASS

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


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

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

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

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

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

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

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

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

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

⚠️ Redacted 2026-09-21T18:2xZ by the seat. Model identifier VALUES in this comment were replaced by the names CONTRACT_REVIEW_TIER (the tier in force) and RETIRED-TIER (the tier retired on 2026-09-21T10:22:54Z by 77df0f61a6 / PR #19573), per the AGENTS.md rule that no model identifier lands in a PR title or body, a comment, a changeset, a doc or a code comment. ⛔ No judgment, figure, coordinate or verdict was changed. Inventory and the standing rule conflict: #19615.


Generated by Claude Code

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

⇒ What is left is one action, and it is not a judgement: put this PR into the merge queue. Everything a lander is supposed to check first is above, in case and re-readable.

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


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

The evidence, on two independent instruments:

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

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

What happens next

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


Generated by Claude Code

Merged via the queue into main with commit 0b4022b Sep 22, 2026
65 checks passed
@os-warren
os-warren deleted the claude/issue-19365-automation-runs-hasmore branch September 22, 2026 01:10
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…form group, not a per-app one (objectstack-ai#15178) (objectstack-ai#19600)

Fixes objectstack-ai#15178

Clause-②: yes

## What is ruled, and what landed

Maintainer ruling batch objectstack-ai#132 item 2 letter ② (comment 5653315643, 「同意」
2026-09-13), quoted verbatim:

> 1. `packages/spec` `TranslationDataSchema` becomes two exports (names
per the file's convention): the platform bundle schema (eleven groups,
`settings` included) and the per-app bundle schema (`settings` absent,
strict — an authored `settings` in a per-app bundle is refused with a
remedy saying it is platform-only). Every reader that consumes a per-app
bundle types against the per-app schema.
> 2. The card's original "removal" disposition is struck: `settings` is
a live platform key (`pickSettingsEntry`, `i18n-resolver.ts:2305`;
console `useSettingsLabel`).
> 3. `check:i18n-walk-parity`: the `settings` exemption disappears with
the per-app key; `LEDGER_CEILING` 3 → 2 in the same PR (the ledger is
governed — declared in the claim).
> 4. Accept-set narrowing on the per-app bundle: `Clause-②: no`;
ADR-0087 semantic entry — a per-app bundle carrying `settings` was
inert, so the conversion drops the group and records a note; no
deprecation window (「创业阶段不渐进」), launch-window convention applies.

The card's own closing line (「⛔ Not a queue card: zero measured pull
today」) is stale and the ruling overrides it. The removal option is
struck; this is the SPLIT.

**Which name took which face, and why.** `TranslationDataSchema` keeps
its name and becomes the **per-app** bundle entry (ten groups); the new
`PlatformTranslationDataSchema` / `PlatformTranslationBundleSchema`
(types `PlatformTranslationData` / `PlatformTranslationBundle`) carry
the eleven-group platform face. The ruling's "names per the file's
convention" is satisfied by the file's existing habit — a qualified
prefix marks the other face, as `ObjectTranslationDataSchema` already
does — and the direction was chosen on a measurement, not on taste:

- The card body itself names the narrowing target "the per-app
`TranslationDataSchema`", and the gate's ledger reason says "removal
from the per-app schema" about that same export.
- Every EXISTING per-app author already types against `TranslationData`:
`stack.translations`, `defineTranslationBundle`, the three examples, the
CLI walker and coverage reader, and the header `os i18n extract` emits
into every scaffolded bundle. Putting the narrowing on a NEW name would
have left all of them accepting `settings`, and re-pointing them would
have re-typed the nine platform `*.generated.ts` files the same emitter
writes.
- Only the genuinely-platform readers had to move, and only one of them
authors `settings` at all.

So "every reader that consumes a per-app bundle types against the
per-app schema" holds by construction here, and the movement fell on the
platform side.

## The ruling's "was inert" is falsified — the record says what was
measured instead

Ruling item 4 describes a per-app `settings` as inert. Measured on
`origin/main` at `1ff3a8f210`, it was **live**:

- `AppPlugin.loadTranslations` (`packages/runtime/src/app-plugin.ts`)
hands each `stack.translations` bundle entry WHOLE to
`II18nService.loadTranslations`.
- `FileI18nAdapter.loadTranslations` deep-merges it into the one
per-locale tree; `getTranslations(locale)` serves that tree.
- Every platform plugin contributes into the SAME tree at `kernel:ready`
— `SettingsServicePlugin` does exactly this with
`settingsBuiltinTranslations`.
- `pickSettingsEntry` reads `pickData(bundle, locale)?.settings`, and
the console's `useSettingsLabel` scans every namespace carrying a
`settings` branch. The tracked liveness ledger
`packages/spec/liveness/translation.json` records that reader with its
evidence pointer and says both doors "merge into ONE tree".

So an app-authored `settings` did not sit unread — but nor did it
override the platform. The app's bundles load in `AppPlugin`'s `start()`
(kernel Phase 2) and the platform's at `kernel:ready` (Phase 3), and
`deepMerge` gives the later source the leaf, so the platform won every
key both defined. What an application actually had was a GAP FILLER on a
namespace it does not own: it rendered only where the platform bundle
carried no string for that key and locale. That makes the ruling's
DIRECTION stronger, not weaker, and it changes only what the record must
say: the drop is a visible change only on the screens where the entry
was FILLING A GAP, and those fall back to the manifest's own English
literal; where the platform already carried the string, nothing changes.
The ADR-0087 semantic entry and the changeset both say so in those words
rather than reciting the house "pure lossless delete" phrase.

## Diff

**Spec (`packages/spec`)**

- `src/system/translation.zod.ts` — `translationDataShape()` becomes
`appTranslationDataShape()` (ten groups) and the `settings` group moves
to `platformSettingsShape()`. `TranslationDataSchema` = per-app, strict,
with a `guidance` prescription for both `settings` and the singular
`setting`; the `setting` alias is deleted, because an alias prescribing
a key the shape now rejects is a suggestion the author cannot take.
`PlatformTranslationDataSchema` = the ten plus `settings`.
`TranslationBundleSchema` is per-app; `PlatformTranslationBundleSchema`
is new. `TranslationItemSchema` is UNCHANGED and still declares
`settings`.
- `src/api/protocol.zod.ts` —
`GetTranslationsResponseSchema.translations` moves to the platform face.
The served document is the merge of every loaded bundle, so it carries
`settings`; leaving it on the per-app face would have published a
declaration the server contradicts.
- `src/system/i18n-resolver.ts` — `pickData` becomes generic over the
entry type, and the settings resolvers take `PlatformTranslationBundle`.
This WIDENS their parameter (every group is optional, so a per-app
bundle is still assignable), so no caller — this repo's or the pinned
sibling's — loses a call.
- `src/conversions/registry.ts` — new D2
`translation-per-app-settings-removed` (`toMajor: 18`,
`retiredFromLoadPath: true`), strips the group from per-app bundle
ENTRIES only. An entry carrying `locale` is a `translation` ITEM and is
left whole; the candidate value must additionally be a dict whose every
key is a declared group, so an `objects` record holding an object
literally named `settings` is not mistaken for a bundle.
-
`src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts`
— the ADR-0087 semantic entry, plus the regenerated `registry.ts` and
the extended step-18 rationale. Regenerated with
`gen:migration-registry`, never hand-merged.
- `src/type-alias-convention.pin.test.ts` — the two new aliases are
pinned isomorphic (both faces are all-optional, no default or transform
anywhere), and the pin count moves 784 → 786 with its receipt.
- `authorable-surface/system.json` — `system/TranslationData:settings`
deleted DELIBERATELY, the tripwire the strict-delete route owes. The
build's own deletion gate then adjudicated it and printed its proof
(objectstack-ai#4650 proof 2): "def not reachable from the 30 metadata-type roots ...
an over-collected entry, never parsed against a metadata document."
Eleven `system/PlatformTranslationData:*` keys arrived in the same run.
- Regenerated: `api-surface/`, `export-origins/`, `declaration-map/`,
`json-schema.manifest/`, `content/docs/references/**`, the
strictness-ledger counts.

**The gate (ruling item 3)** — `scripts/check-i18n-walk-parity.mjs`: the
`settings` ledger row is gone, `LEDGER_CEILING` 3 → 2, the class-level
note and the recorded self-test samples move with it. **This is the
shrinking direction of a governed, shrink-only ledger**, declared in the
claim and declared here. The gate's own rule at the ratchet says growth
is the reviewed act; slack fails too, which is why the ceiling had to
move in the same diff.

**Platform readers** —
`packages/services/service-settings/src/translations/{en,es-ES,ja-JP,zh-CN}.ts`
and their `index.ts` move to `PlatformTranslationData` /
`PlatformTranslationBundle`. They are the only bundles in the repo that
author `settings`.

**Published prose** — `content/docs/protocol/kernel/i18n-standard.mdx`
published the eleven-group list as "rejected by name at both authoring
doors", and `skills/objectstack-i18n/SKILL.md` published the same list
to customer projects. A **third** published carrier,
`content/docs/ui/translations.mdx`, taught `settings` as
app-translatable in its own table. **All three are corrected** — an
earlier draft of this sentence said "both", before the guide correction
landed. `docs/qa/platform-checklist/areas/i18n.json` had a symbol anchor
on the renamed shape function and a clause naming the wrong face.

## Verification

⚠️ **Provenance corrected — this table was NOT read at the final
commit.** It was read at `5283099838`, which is the **6th of this
branch's 10 commits**; four have landed since (`b33ea66cb3`,
`d5e6b43977`, `b1e7984040`, `8dcd6a42ae`). An earlier draft of this line
called it "the final commit on this branch", and the whole Verification
table, the Tests section and the Reverse verification paragraph hang off
it — so as written the body claimed readings that covered the derivation
change and the guide correction. They did not.

⛔ Nothing below is therefore unmeasured at head; it is re-measured
elsewhere, not here. What covers the later commits is the at-tier
contract review of head `8dcd6a42ae` (record on card objectstack-ai#15178), which
re-derived the carrier sweep over all 9156 tracked files, rendered the
migration TODO live, ran the derivation ablation in memory, and read CI
by job conclusion. ✅ One row IS unaffected and re-measured at head: the
**skills** table — `skills/objectstack-i18n/SKILL.md` was last touched
at `2602ccec10`, earlier than `5283099838`, and every figure in it
reproduces at head (494→496 lines, 4713→4752 tokens, ceiling 6338,
headroom 1586).

| Instrument | Reading | Which side it can fail on |
| --- | --- | --- |
| `check:i18n-walk-parity` | `10 declared group(s), 8 walked, 2
exempted` | The `10` is the reading that discriminates: the per-app face
has ten groups, the platform face eleven. It fails if a declared group
has no emitter and no ledger row, if a ledger row is stale, and — the
ratchet — if the ceiling has slack. Its `--self-test` battery is 43
cases. |
| `check:authorable-surface` (inside `gen:schema`) | deletion allowed
with a printed proof; 11 keys added | It refuses ANY authorable key that
vanishes without one of four proofs, and it refused this diff on the
first attempt — that refusal is the control. |
| `check:generated` | 15 of 15 artifacts current after `--fix`
regenerated the 5 it proved stale, each re-checked | It can fail on a
stale artifact in either direction. |
| `check:adr-0087-registration` | `1 declared-breaking changeset(s),
each carrying an ADR-0087 disposition` | It can fail on a breaking
changeset with no marker; it reported `0 non-breaking changeset(s) seen`
before the changeset was committed, which is the control leg. |
| `check-changeset-no-major` | `no major bump` | ⚠️ Its clause-② axis
printed `LEVEL AXIS: NOT APPLICABLE` — there is no PR payload on a local
run, so it can fail HERE only on the `major` axis, not on the
declaration. |
| `check:spec-parsed-alias` | `1455 bare aliases, 786 pinned isomorphic,
669 paired` | It failed first with both new aliases named — that red is
the control. |
| `check:type-check-debt` | `4 ledger entr(ies) re-measured, 53 raw tsc
errors, none above its recorded number` | Re-run after a rebuild; an
earlier run exited 3 (PREREQUISITE NOT MET) on a dist older than its
sources, which is NOT a reading. |
| `pnpm lint` (`eslint . --no-inline-config`) | exit 0, whole repo, no
narrowing | Ran over the repo's own configured universe, so no narrowing
claim is needed. |

**Gate families**: `scripts/pm/dispatch-gates.mjs` derived 139 for this
change set; `--ran` with exit codes recorded reconciles **139 accounted,
138 run, 0 UNRUN, 1 NOT MEASURED**. The one is
`check:dual-build-cjs-loads`, which exits 3 (PREREQUISITE NOT MET)
without a full workspace build — recorded as NOT MEASURED and left to
CI, which builds everything. The reconciliation's own caveat stands: it
answers what this card DERIVES against what was RUN, and the
artifact-roster families, the wide-population families and the
path-scheduled CI jobs are outside that total.

**Tests** (`turbo run test`, `--concurrency=2`): `@objectstack/spec` 508
files / 14,902 tests, `@objectstack/lint` 106 files,
`@objectstack/service-settings` 33 files,
`@objectstack/platform-objects` 51 files, the three examples 41 files,
`@objectstack/cli` unit tier 222 files / 3,141 tests — all pass. `turbo
run typecheck` over spec, cli, lint, platform-objects, service-settings,
service-i18n, runtime and rest: 64 tasks, all pass.

**Reverse verification (one-shot, not left in the tree).** With the fix
committed, `settings` was put back on the per-app shape through
`scripts/ablation-replace.mjs` — the mutation is proved on disk (anchor
1 → 0, blob `3a27c26f6a5c` → `a0b6be68af3e`) — and the two refusal pins
went RED by name. Restored with `--restore`: blob back to
`3a27c26f6a5c`, equal to HEAD, and `git diff HEAD` empty. The predicted
direction was "turns red", and that is what was observed.

## Skills bundle readings (`skills/**` is a governed surface)

Required because the diff touches a published skill. This is a
CORRECTION, not an expansion — the added sentence exists because the old
one became false.

| Reading | Before | After | Delta |
| --- | --- | --- | --- |
| `skills/objectstack-i18n/SKILL.md`, lines | 494 | 496 | +2 |
| `skills/objectstack-i18n/SKILL.md`, tokens | 4713 | 4752 | +39
(ceiling 6338, headroom 1586) |
| Whole bundle, all `SKILL.md` lines | 6145 | 6147 | +2 |
| Whole bundle, tokens (shipped tree) | 140374 | 140413 | +39 |

Before-tokens were measured by restoring the base file, reading
`check-skills-token-ratchet`, and restoring with proof (blob equal to
HEAD, `git diff HEAD` empty). `check-skills-token-ratchet` passes: 34
authored files within their ceilings.

⚠️ **Landing tier.** `skills/**` is Tier H on the governed register, so
this PR's landing is Tier H on one path hit. It is left as a draft
awaiting that record. If the seat would rather land the rest through the
queue, the remedy the directive names is to split
`skills/objectstack-i18n/SKILL.md` off into its own PR — but ⛔ not to
ship the corrected schema while the published skill still teaches the
key the parse now refuses.

## Declared file-surface deviations

The claim declared the surface as `translation.zod.ts` + siblings, the
walk-parity gate + fixtures, `i18n-extract.ts` + tests,
`migrations/entries/semantic/` + `registry.ts`, and `.changeset/`. Five
paths outside it were edited, each forced by the ruling rather than
chosen, and none widened silently:

1. `packages/spec/src/api/protocol.zod.ts` — the served response must
type against the platform face or it declares a shape the server
contradicts. ⚠️ **This path is held by open PR objectstack-ai#19493's sibling
declaration set — specifically it was declared disjoint against objectstack-ai#19543,
which enumerates it.** One line changes (the import) plus one line in
the response schema, plus a docblock. A textual conflict is possible;
this PR is not asking to land first.
2. `packages/spec/src/system/i18n-resolver.ts` — `pickSettingsEntry`
reads `.settings`; without this the package does not typecheck. The
parameter is widened, not narrowed.
3. `packages/services/service-settings/src/translations/*` (5 files) —
the only bundles that author `settings`; type annotations only.
4. `packages/spec/src/conversions/registry.ts` — the D2 conversion
ruling item 4 asks for ("the conversion drops the group and records a
note"). The semantic entry alone records the judgment but rewrites
nothing.
5. `content/docs/protocol/kernel/i18n-standard.mdx`,
`skills/objectstack-i18n/SKILL.md`,
**`content/docs/ui/translations.mdx`**,
`docs/qa/platform-checklist/areas/i18n.json` — published claims this
change makes false, plus one symbol anchor the rename broke
(`check:platform-checklist` went red on it and is green again). ⚠️
**`content/docs/ui/translations.mdx` was added to this enumeration after
the fact:** it was edited in commit `b1e7984040` and an earlier draft of
this item listed only three paths, under-declaring the deviation by one.

`packages/spec/src/migrations/registry.ts` is the declared overlap with
open PR objectstack-ai#19493. Its **generated regions** were regenerated with
`scripts/pm/os-regen-merge.sh`'s generator (`gen:migration-registry`)
and never hand-merged. ⚠️ **One line in that file IS hand-written, and a
reviewer of a generated file should be told:** `registry.ts:47` carries
a value import of `TranslationDataSchema`, **outside every
`<os-generated …>` region** (the first region opens at `:1142`). It is
necessary, not an oversight — `build-migration-registry.ts`'s
`parseEntry` deliberately drops imports and carries only the
initializer, so an entry that derives its text from a value needs that
value in scope in the registry itself. The comment immediately above the
import says so. It survives regeneration because `renderRegistry`
splices only between the region markers, and `check:generated` is the
instrument that would fail if that round-trip were unstable.
`packages/cli/src/utils/i18n-extract.ts` was NOT edited — the walker
does not move, because `settings` never had an emitter.

## Acceptance notes

- **The `translation` metadata-type door is untouched and still accepts
`settings`.** The ruling names the per-app BUNDLE, and
`packages/spec/liveness/translation.json` is that item's ledger, so
narrowing the item would have moved a ledger outside this card's
surface. It leaves a question worth a decision rather than a silent
choice: an app admin authoring a `translation` item through Studio can
still write `settings`, and `authored-translation-sync` merges the raw
stored payload into the same served tree, so the refusal this PR adds at
the file door does not reach the metadata door. Raised in the report,
not decided here.
- Platform bundles that author only shared groups (`platform-objects`,
the five plugins, the other services) keep the narrower
`TranslationData` / `TranslationBundle` types. They are assignable, and
re-typing ~20 files that never carry `settings` would be churn with no
contract effect. noted, not filed.
- `packages/lint/src/validate-translation-references.ts` still lists
`settings` among the groups it deliberately does not judge. The branch
is now unreachable for a per-app stack rather than wrong. noted, not
filed.

## 维护者速读(草稿)

- **改了什么。** 翻译包的类型一分为二:`TranslationDataSchema` 从此只表示「应用自己写的那一份」,十个分组;新的
`PlatformTranslationDataSchema` 是平台那一份,十一个,`settings` 留在它那里。应用再写
`settings` 会被按名字拒绝,并告诉作者这是平台专属。
- **为什么改。**
一个类型同时代表两种包,是这张卡上每一次误读的源头:当初的普查拿「按应用问」的问题去问一个分不出应用和平台的类型,得到零,就差点把平台每天在读的键删掉。更要紧的是实测结果:应用写的
`settings` 并不是没人读 —— 它和平台那一份合进同一棵已服务的树。但它**不是覆盖者**:应用包在 kernel 第 2
阶段加载、平台包在第 3
阶段,合并时叶子归**后到**的一方,所以**两边都定义的键,平台永远赢**。应用那份实际是个**补缺者**:只在平台包对那个键、那个语言没有字符串时才显示。
- **风险与代价(含回滚)。** 风险在于:升级后,**两边都有的键屏幕上根本不变**(平台本来就赢);只有**应用包在补空**的那些键会变
—— 那里平台压根没有字符串,所以屏幕上会回落到 **manifest 自己的英文字面量**,而不是「平台自带的字」。⚠️
一个本地化部署里冒出一串英文,是比「换成平台的措辞」更响亮的一种结果,请按这个来衡量。这是有意的,changeset 与 ADR-0087
条目都写明了,不是静默变化。发布面动了,所以带 `minor` changeset(发射窗口惯例,`major`
会被门禁拒收)。回滚就是回滚这个 PR:没有数据迁移、没有存储改动,`os migrate meta` 的那条转换只在作者主动运行时改源码。
- **席位意见。** 这一轮的方向是 dev **第一手实测**出来的,不是复述:证伪器跑真链路,外加两个亮控 ——
一个把加载顺序反过来证明仪器对顺序敏感,一个用平台没翻译的命名空间证明 app
那份真的被加载且在服务。结论从「覆盖平台文案」改成「只填平台没有的空」,所以 ADR
条目按实测写是对的,原裁决的**方向**不动。本席**不建议拆**技能文件:拆了等于在窗口期里让已发布技能继续教一个已被拒收的键,而
`skills/**` 这道 Tier H 的门你本来就得为它开一次。⚠️ 另:本 PR
先前那份契约复审记录**已作废**(跑在已退役的档位上,台账见 objectstack-ai#19603),新的达档复审在 the current
`CONTRACT_REVIEW_TIER` 上重跑;⛔ 结果出来之前本席不做任何落地动作,也不翻 ready。
- **你要做的。** ① 这个 PR 碰了 `skills/`,按规矩属于 Tier
H,落地要维护者的那句话;若不想为一条文案更正开这道门,可以把那个技能文件单独拆一个 PR——但⛔
不能一边发布新契约、一边让已发布技能继续教一个现在会被拒收的键。② 裁决里「was inert」这句与实测不符(它是活的),ADR
条目按实测写,请确认这个改写符合原意。③ `translation` 元数据门仍接受
`settings`,那是本卡范围之外的一个口子,见上面的验收注记。

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…iConversationsResponse declares hasMore (objectstack-ai#19543) (objectstack-ai#20192)

Fixes objectstack-ai#19543

Clause-②: yes

This finishes the card: door ③'s spec half and door ④. Door ① landed in
objectstack-ai#19493 and door ② is absorbed by objectstack-ai#17158, so nothing of the card's ruled
work is left after this PR. Door ③'s server half is
objectstack-ai/cloud#2426 (open) and is ⛔ not touched here.

Rulings executed (card comment 5825819437, maintainer's words verbatim):

> door ④: 「退役,统一走 /meta/flow」
>
> door ③: 「**Ruled**: the list is **newest first**.」 · 「**This card owns
the spec half.** `ListAiConversationsResponseSchema` gains `hasMore`
(and `nextCursor`, if declared, meaning "the id of the last conversation
on the page"), `cursor` is described, and the SDK doc stays "newest
first".」 · 「Land them together, or land cloud after this card.」

## Door ④ — `GET /api/v1/automation` is retired; the flow list is `GET
/api/v1/meta/flow`

The route's contract described a capability no build delivered: the
request declared `status` / `type` / `limit` (default 50) / `cursor` and
the handler read none of them; the response declared `FlowSummary[]` +
`total` + `nextCursor` + `hasMore` and the handler answered bare names
beside a literal `hasMore: false`.

| surface | before | after |
|:--|:--|:--|
| `packages/runtime/src/dispatcher-plugin.ts` | `server.get(base +
'/automation')` mounted (and its environment-scoped twin) | not mounted
for GET; `POST` at the same path (createFlow) unchanged |
| `packages/runtime/src/domains/automation.ts` | `GET /` branch →
`listFlows()` | no branch; the domain declines (`handled: false`); the
`objectstack-ai#7900` audit note kept for the surviving reads |
| `packages/runtime/src/route-ledger.ts` | row `GET /automation` ·
`automation.list` | row removed; census sentence 82 → 81
(`check:route-ledger-census --fix`) |
| `@objectstack/client` | `automation.list()` | removed (compile error
on use) |
| `@objectstack/spec/api` | `ListFlowsRequestSchema`,
`ListFlowsResponseSchema`, `FlowSummarySchema` + 5 types | removed —
`FlowSummarySchema` had no reader but `ListFlowsResponseSchema` (grep of
the tree: its own test and the ADR-0122 pin only) |
| `AutomationApiContracts` | 9 entries incl. `listFlows` | 8 entries;
none is a GET at the bare path |
| ADR-0087 | — | D3 semantic entry `automation-flow-list-route-retired`
+ `RETIRED_DEFS_BY_MAJOR[18]`: `api/ListFlowsRequest`,
`api/ListFlowsResponse`, `api/FlowSummary` |

Every other `/automation` route is unchanged (runs, `/_status`, actions
and connectors catalogs, create / update / delete / trigger / toggle /
clone / resume / cancel / restore-suspension / screen). objectstack-ai#20056's table
stays true: `automation-api-contract-mounts.test.ts` (every contract
route is mounted at the default prefix AND is a ledger row) is green
with the entry and the row gone together.

**What the wire answers now — measured, not assumed.** On a real socket
(`HonoServerPlugin` + `createDispatcherPlugin`,
`dispatcher-plugin.anonymous-gate.integration.test.ts`): `GET
/api/v1/automation` answers **`405 METHOD_NOT_ALLOWED` with `Allow:
POST`**, anonymous and signed in alike, byte-identical (once the echoed
path is factored out) to a GET on the POST-only control path
`/api/v1/automation/:name/toggle`, and `listFlows` is never called. It
is a 405 and not a 404 because `POST` still lives at the path; the
host's own unmatched answer says exactly that, and the retired route
leaves no text of its own. A transport that forwards every automation
path to the dispatcher (the `@objectstack/hono` catch-all) gets
`handled: false` and renders its own 404; the domain's anonymous floor
still answers 401 first there (pinned in
`anonymous-gate-actions-automation.test.ts`).

## Door ③ — spec half: `ListAiConversationsResponseSchema` gains
`hasMore`

Describe texts, quoted exactly (they ship in the JSON Schema and the
references page):

- `cursor`: "The `id` of the last conversation on the previous page. The
next page starts with the conversation created immediately before it,
continuing newest first. Omit it to read the first page. An id that
names no conversation of the caller is refused rather than read as the
start of the list."
- `conversations`: "The caller's conversations, newest first — ordered
by creation time, then `id`, both descending"
- `hasMore` (new, **required**): "Whether at least one more conversation
follows this page. When `true`, send the `id` of the last conversation
in `conversations` as `cursor` to read the next page."

`nextCursor` is **not** declared. The SDK is unchanged:
`client.ai.conversations.list()` still resolves to the array and its doc
still reads "newest first"; `content/docs/api/client-sdk.mdx` shows the
next-page call (`cursor` = last id held).

### The open choice this PR settles: `hasMore` required, `nextCursor`
absent — four axes

| axis | `hasMore` required (taken) | `hasMore` optional |
|:--|:--|:--|
| 实际业务需求 (measured) | Readers today: none parse it — the SDK returns the
array, objectui's `useConversationList` reads `?limit=50` page one only
(`packages/app-shell/src/hooks/useConversationList.ts` at main
`5c61e524` and pin `f8a9d0fb`). The need it serves is the truncated
sidebar: a caller with more than `limit` conversations cannot tell a
full page from the last one. The only producer is cloud, which
cloud#2426 makes compute it. | Same readers; the flag could be absent
forever on a conforming server, so the need is served only by
convention. |
| 项目长远合理性 | The declaration states what every server must do; the window
until cloud#2426 lands is ruled ("land cloud after this card") and
named, not baked in. | Bakes the transition window into the permanent
contract. |
| 防 AI 写错 | A server or mock written against
`ListAiConversationsResponse` without `hasMore` is a tsc error and a
parse refusal (pinned). | Omission is spec-valid; every reader needs an
absent-means-unknown fallback — the consumer-side tolerance the frame
rejects. |
| 创业阶段不扩散 | No staged window, no new gate, no new field beyond the ruled
one. | — |

`nextCursor`: zero readers anywhere, and by the ruling's own definition
it equals the last conversation's `id`, already on the page — a second
field is a second place for one value to disagree (startup axis: no
pull, no surface). What the SDK does when `hasMore` is absent: nothing —
it never reads it, so the window between this PR and cloud#2426 changes
nothing any in-repo caller sees.

## Zone-2 measurements (PM mechanism assumptions)

1. At `8d1f7ab7`: `ListAiConversationsResponseSchema` was `{
conversations }` only (`protocol.zod.ts:2946`); `ListFlows*` /
`FlowSummarySchema` at `automation-api.zod.ts:59-109`; `listFlows` at
`:661-666`; all three exported in `api-surface`, `declaration-map`,
`export-origins` — **confirmed**.
2. `client.automation.list` / `ListFlows*` / `FlowSummary` / a bare GET
of the list path: **zero callers** outside their own tests and the
ledger row in objectstack (branch base `8d1f7ab7`), objectui pin
`f8a9d0fb` and main `5c61e524`, cloud main `48d70663`. Positive controls
on the same instruments: objectui
`apps/console/src/pages/developer/FlowRunsPage.tsx:152`
`client.meta.getItems('flow')` and
`packages/app-shell/src/views/setup/PackagedAutomationPage.tsx:144` `GET
/meta/flow` hit at both objectui refs; cloud
`packages/service-ai/src/routes/ai-routes.ts` hit for the conversation
route. objectui's `useApiDiscovery.ts` names `/api/v1/automation` only
as a route prefix with a `POST /trigger` endpoint — not the list.
**Confirmed.**
3. objectstack-ai#20056 keeps `AutomationApiContracts` equal to the served paths; the
removal takes the entry and the mount together, and its pin is green —
**confirmed**.
4. cloud main `48d70663`: `ai-routes.ts` answers `{ conversations }`
with no `hasMore`; `objectql-conversation-service.ts` orders ascending
and keyset-pages on `(created_at, id)` — **confirmed**; cloud ⛔ not
edited.
5. Generated surfaces, regenerated with the tooling, never by hand
except the two deletions the gates prescribe by name:
`json-schema.manifest/api.json` (−3 keys) and
`authorable-surface/api.json` (−16 keys, reported by the build as "def
no longer emitted by this build" — path 3); then
`gen:migration-registry`, `check:generated --fix` (api-surface,
export-origins, declaration-map, references docs, strictness-ledger
counts), `gen:test-typecheck-debt` (runtime ledger −1 signature, the
`listFlows` TS2339 it recorded vanished), `check:route-ledger-census
--fix`. `authorable-surface.base.json` untouched. Three merges of
`origin/main` went through `scripts/pm/os-regen-merge.sh` (the third
brought objectstack-ai#20194, see Patch round 1); after each, `check:generated`
reported all 15 artifacts current and main's sibling entries
(`ui-report-joined-container-selection-refused`,
`export-job-family-retired`) are present in `registry.ts`.

## Pins (accept and refuse)

-
`packages/runtime/src/dispatcher-plugin.anonymous-gate.integration.test.ts`
— real socket: GET → 405 + `Allow: POST` + `METHOD_NOT_ALLOWED` +
`details` (anonymous and with a session), byte-equal to the control,
`listFlows` never called; POST at the same path still mounted (anonymous
→ 401); the inventory-privacy probe moved to `GET
/api/v1/automation/_status` → 401.
- `packages/runtime/src/domain-handler-registry.test.ts` — real
`dispatch()`: `GET /automation` → exactly `{ handled: false }`,
identical to a never-served sub-path, `listFlows` never called; `GET
/automation/_status` on the same dispatcher → 200 (anti-vacuity).
-
`packages/runtime/src/domains/anonymous-gate-actions-automation.test.ts`
— anonymous `GET /` still 401 (floor precedes routing), authenticated
`GET /` unhandled; inventory reads moved to `/_status`.
- `packages/runtime/src/http-dispatcher.test.ts`,
`automation-write-capability-gate.test.ts`,
`automation-run-read-permission-gate.test.ts`,
`http-dispatcher.tenancy-posture-outage.test.ts` — the cases that used
the list as a convenient probe now read `/_status` (their subjects —
service resolution, stub/degraded slots, tenancy verdicts, the objectstack-ai#7900
audit — are route-independent); the retired row leaves the audit table
with a note.
- `packages/client/src/client.test.ts` — `'list' in client.automation`
is false, with a `@ts-expect-error` on the access (the client test layer
compiles with 0 debt, so the directive is live); `meta.getItems('flow')`
targets `GET /api/v1/meta/flow`.
- `packages/spec/src/api/automation-api.zod.test.ts` — the three names
are not exported (with a surviving-export control); the contract map has
8 entries, no `listFlows`, no `GET /api/v1/automation`, and still `POST
/api/v1/automation`.
- `packages/spec/src/api/protocol.test.ts` — accepts `hasMore`
true/false and keeps it; refuses a page without it: issue `code`
`invalid_type`, `path` `["hasMore"]`, message `Invalid input: expected
boolean, received undefined`; refuses `hasMore: 'false'`; the response
shape is exactly `conversations` + `hasMore`; the request keeps
`agentId` / `limit` / `cursor`.
- `packages/spec/src/type-alias-convention.pin.test.ts` — the
`FlowSummarySchema` pin leaves with the schema. At the merged head the
count is **786**: objectstack-ai#17158 (landed first) took 790 → 787 and this PR's
receipt reads 787 → 786. Re-derived from the merged file (`grep -c
'^export type Iso_'` = 786; the test's own recompute agrees), not by
arithmetic.
- `packages/qa/dogfood/test/authz-probe-blind-spot.census.ts` —
`route-ledger.ts` population 82 → 81 (controls re-measured by grep: 82
at base, 81 now), and its prose reading "82 rows over 21 domains" → 81;
`authz-conformance.matrix.ts`'s objectstack-ai#17111-pinned docblock figure (82 rows /
21 domains) → 81 / 21, and the dated note in
`authz-ledger-population.baseline.ts` records the 82 → 81 move — rows
and domains derived from the `ROUTE_LEDGER` table (81 rows, 21 distinct
domains; domains unchanged);
`showcase-anonymous-deny-surfaces.dogfood.test.ts` — the automation
probes read `/automation/_status`.

**Ablation (one-shot, not a standing test).** With the fix committed,
`scripts/ablation-replace.mjs` re-planted the `GET base + '/automation'`
mount in `dispatcher-plugin.ts` (anchor 1 → 0, blob `acbf6f93` →
`01d21238`, marker count 1): the socket pin went red — `expected 401 to
be 405` — and the restore leg proved blob == HEAD and an empty `git diff
HEAD`. The first attempt was a no-op the tool refused (the replacement
contained its own anchor); the second is the one reported.

## Tests and gates (read at the final head, quoted from real output)

Head **`f3ed706f`** (after Patch round 1). The test matrix ran at
`3b8a66d6`; the only change from `3b8a66d6` to `f3ed706f` is the revert
of a comment in `.github/workflows/lint.yml` (`git diff --stat`: 1 file,
+2 / −3), which no test suite reads. The gate union and the citation
check ran at `f3ed706f`. Heavy runs went through
`scripts/pm/os-verify-lock.sh`.

| run | reading |
|:--|:--|
| `@objectstack/spec` `vitest run --project local` | 540 files, 15872
passed, 2 todo |
| `@objectstack/runtime` `vitest run --project local` | 279 files, 3909
passed, 1 skipped |
| `@objectstack/client` `vitest run` | 50 files, 636 passed |
| `@objectstack/dogfood`: the whole `authz-conformance.test.ts` (the
objectstack-ai#17111 pins that were red in CI),
`showcase-anonymous-deny-surfaces.dogfood.test.ts` (real showcase boot),
`authz-probe-blind-spot.test.ts` | 3 files, 130 passed |
| `typecheck` for spec, runtime, client and dogfood | exit 0 each; the
test layers are OK (runtime ledger: 190 errors / 68 signatures, one
fewer than base; client: 0) |
| `pnpm --filter @objectstack/spec check:generated` | all 15 artifacts
current |
| `node scripts/check-issue-citations.mjs` (live, diff-scoped) | "✅
check-issue-citations: every citation this change adds resolves (or is a
declared cross-repo reference)." — 22 judged: 18 resolve, 2 resolve as
pull requests, 2 cross-repo |
| `dispatch-gates.mjs --commands` union: 116 families derived for this
diff at `f3ed706f` | 116 run, all exit 0. `--ran`: "116 derived
famil(ies) accounted for — 116 run, 0 NOT-MEASURED (a DERIVED zero — all
116 recorded an exit code and none of them is 3)" |
| roster gates whose roster sits in this diff's directories:
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`, `check:route-ledger-census` | exit 0 each |
| eslint, narrowed to the added or modified `.ts`/`.mjs` files (round 0)
| `--format json`: 24 files, 0 errors, 0 warnings. Population: the
`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` block in
`eslint.config.mjs`. Invariance: that config enables no type-aware
linting (`eslint.config.mjs:328`), so this diff cannot change the
verdict on an untouched file. The repo-wide `pnpm lint` runs in CI. |

Consumer direction: the removed spec exports have zero importers outside
`packages/spec`, so the downstream check is the client and runtime
typechecks above. The removed SDK method has zero callers in all three
repos. NOT MEASURED locally and left to CI: the path-scheduled CI jobs
(Test Core shards, Temporal Conformance, the full Dogfood gate, Build
Core) and the workspace type-check lanes.

## Patch round 1 — the two CI reds at `eb08fb39`, fixed

1. **Lint & Repo Gates → `check-issue-citations`**: "2 citation(s) THIS
CHANGE ADDS do not resolve". The citation was `objectstack-ai#8715`, which was
allocated but never resolved (REST 404). It appeared in
`retired-defs/18.api__ListFlowsRequest.ts` and in its generated copy in
`registry.ts`. The file now names the precedent by its ADR-0087 entry
id, `package-rollback-response-retired`, and its
`api/PackageRollbackResponse` row, with no guessed number. The registry
was regenerated.
2. **Dogfood Regression Gate (1/3) → `authz-conformance.test.ts` objectstack-ai#17111
pins**: "packages/runtime/src/route-ledger.ts: docblock says 82 rows,
the table holds 81". The fix is the matrix docblock → "81 rows / 21
domains". I derived both numbers from the `ROUTE_LEDGER` table: 81 rows,
21 distinct domains, so the domain count did not move. The same sweep
fixed the census reading in `authz-probe-blind-spot.census.ts` and the
dated note in `authz-ledger-population.baseline.ts`. Two statements
remain that are dated and historically true: the reading in
`scripts/check-route-ledger-census.mjs`'s header ("at the commit that
added this gate") and the `lint.yml` comment (see Acceptance notes).
3. **Merge**: after objectstack-ai#20194 (objectstack-ai#17158) merged (`4db1bf17`, an ancestor of
this head), `origin/main` came in through `os-regen-merge.sh` as merge
`fd76315a`:
- `registry.ts` took main's side and was then regenerated from both
sides' entries (+95 lines, no deletions).
- The type-alias pin test was resolved by hand, keeping both receipts.
- Main's generated shards were taken and regenerated in `3b8a66d6`, with
this PR's two hand deletions (manifest −3, authorable-surface −16)
re-applied on top of main's bytes.
- The generated delta against `origin/main` is exactly this PR's: the
three retired defs are out, `ListAiConversationsResponse:hasMore` is in,
and strictness-ledger `api/` goes 435 → 432.

## Acceptance notes

- `packages/adapters/hono/src/hono.test.ts:454` "GET /api/automation
delegates to dispatch()" is an adapter-delegation test against a mock
dispatcher and stays true (the catch-all forwards any path); not edited.
carrier: none.
- `packages/qa/dogfood/test/authz-conformance.matrix.ts:251` names `GET
/automation` in prose describing the pre-objectstack-ai#5519 ungated state;
historical, not edited.
- With no automation service registered, a catch-all transport answers
the retired path with the domain's 501 (the capability probe precedes
routing domain-wide, by design, so a 501-vs-404 does not fingerprint
deployments); pre-existing ordering, unchanged.
- The SDK's `ai.conversations.list()` does not surface `hasMore` (out of
this card's ruled scope; see the report's open question).
- `.github/workflows/lint.yml`'s census-gate comment still says 82
(historical prose, left as is).
- `authz-probe-blind-spot.census.ts`'s census paragraph also says "Nine
more ledgers exist repo-wide (290 rows in total)". The nine other
`*-route-ledger.ts` files hold 117 rows today, and all eleven hold 282
at the base and 281 here, so the 290 was already stale before this PR.
It is left as is; carrier: none.
- `packages/services/service-automation/README.md` drops the list line
and points at `GET /api/v1/meta/flow`;
`content/docs/api/plugin-endpoints.mdx` teaches both doors;
`docs/qa/platform-checklist/areas/access-security.json` re-points its
automation probes to `/_status` and to a single-flow read.

Changeset: `.changeset/19543-list-doors-3-4.md` — `minor` for spec /
client / runtime, a BREAKING banner with FROM → TO per surface, the
Clause-② line with its `(narrowing)` arm, and the ADR-0087 disposition
marker `registered automation-flow-list-route-retired`. No
`content/docs/releases/` edit.

Written by the `domain:spec` seat-1 dispatch (session
`session_01Rjy9MeetSfq34PKn81CRiN`), branch
`claude/issue-19543-list-doors-3-4`.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants