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