feat(spec)!: retire the bare string sort clause on the list-view doors — the PRODUCER half of the sort seam - #17914
Conversation
`ListViewSchema.sort` accepted both the legacy string clause (`"field desc"`)
and the structured `{ field, order }[]` array. objectui ruled one sort
orthography platform-wide -- the array (objectui#8221, decision batch #77,
2026-09-07, option B) -- and objectui PR #8758 executes it: its
`convertSortToQueryParams` refuses a runtime string outright.
That made this schema the PRODUCER of documents its own consumer rejects: a
view authored with `sort: 'created_at desc'` validated here, cleanly, and then
failed downstream, with the author told off by the wrong layer. The grade sat
on that asymmetry, not on the string.
`sort` survives as a key, one union arm lighter, so this is a VALUE narrowing
with no `retiredKey()` tombstone to hang a prescription on. The surviving array
member's own `error` map carries it, keyed on `issue.input` being a string --
the shape `view.type`'s retired `'page'` and `view.exportOptions`' retired
`'pdf'` already use here. Every other invalid value, and any string reaching a
descendant, keeps zod's default report.
The narrowing is not free, so the population was measured rather than assumed:
a tree-wide census read as structures (not counted as tokens) found the clause
authored on three live sites, all converted here -- the shipped showcase list
view, the frozen lint snapshot of that same shape, and the published
objectstack-ui list-view rule. It therefore carries a D2 conversion,
`list-view-sort-string-clause-to-array`, wired into the protocol-18 chain step:
the rewrite is lossless and wholly mechanical. A clause that does not parse as
`<field> [asc|desc]` is deliberately left alone -- the `'-field'` dialect
belongs to `RecordRelatedListProps.sort`, never reaches
`convertSortToQueryParams`, and retiring it was not ruled.
Retiring the string arm also made the sort entry's own keys visible to the
liveness walk, which reported `view/list.sort` as an undeclared container.
Drilled with in-repo evidence rather than recorded in the shrink-only baseline:
`normalizeSortNodes` reads both `field` and `order`.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 1 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 136 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 c01aa19bafb58b112648a619ada5c8f55c8da67e && git checkout c01aa19bafb58b112648a619ada5c8f55c8da67e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 225197cdbaff647a8b12e52bcb9d7bbb5b07ed08 c7ae2c1026169d93a75f103c27190537052330fc && git checkout -B drift-repro 225197cdbaff647a8b12e52bcb9d7bbb5b07ed08 && git merge --no-ff c7ae2c1026169d93a75f103c27190537052330fc
node scripts/docs-audit/affected-docs.mjs --json 225197cdbaff647a8b12e52bcb9d7bbb5b07ed08
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 34742873716 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…stview-sort-legacy-string-retired
…d ledgers The merge with main dropped one of two count bumps. This branch's `view` row went 77 -> 78 and main's `action` row went 43 -> 44, and each side wrote the SAME new bytes on the `**total**` row (`**862**` / `**973**`, each counting only its own addition). A text merge keeps one copy of an identical change, so the merged file carried both per-type rows and a total that had only been advanced once: 862/973 where the walk measures 863/974. `check:liveness` graded that as a stale generated artifact and exited 1, which is what took the merge-queue build down: 15 cases in scripts/liveness/check-liveness.test.ts spawn the real gate against a verbatim copy of the shipped ledgers and assert exit 0, so every one of them went red on the merge commit while the PR head alone stayed green (the PR-side subset never built the merge). Regenerated wholesale rather than hand-patched, per the gate's own instruction: the count columns are generated (#7377) and hand-patching puts the numbers back in the merge path where they merge clean and wrong. content/docs/references/data/object.mdx rides along for the same reason in the other direction: the os-regen driver kept this branch's side and dropped main's new `action.execution` row; the regeneration re-derives both that row and this branch's narrowed `view.sort` type. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
维护者速读这个 PR 修好了,但最后一步合并只能您来按——它碰了 上次为什么被合并队列踢出来: 不是这个 PR 写错了。它和已经进 main 的 #17912 各自给同一个生成文件的总计行加了 1,两边写出的是一模一样的一行字节;git 的文本合并遇到两处相同的改动只保留一份,于是合并后的总计只涨了一次(862/973),而闸门现场一数是 863/974。闸门把它判成"生成物过期"退出 1,53 个用例里 15 个红。PR 本身单独跑是绿的——所以 PR 侧 CI 看不见,只有队列建出合并树才看得见。 修法: 整份重新生成( 验证: 现在的状态: 新 head Seat record (English, for the audit trail)Posting seat: Governed-surface reading, taken on the FINAL 14-path file list (not recalled from earlier in the session): Lit control, same gate, same session, a two-path subset with no register hit: ⇒ the instrument distinguishes; exit 3 here is a reading, not a stuck gate. Repair commits: Reproduction, both directions — the round did not take the queue bot's word for it:
⛔ Not done by the seat and not to be done by it: ready-flip, enqueue, auto-merge arm, approving review. This comment is the handoff, not a request to reconsider the regime. One out-of-scope reading worth keeping (noted, not filed — no repo file owns it): the Generated by Claude Code |
Fixes #17053
Clause-②: no
skills/**, so under Prime Directive #14 it is human-merge only. ⛔ Not queued, not ready-flipped, no auto-merge armed by this round.The seam, which is what this is graded on
objectui ruled one sort orthography platform-wide — the array (objectui#8221, decision batch #77, 2026-09-07, option B) — and objectui PR #8758 executes it:
convertSortToQueryParamsrefuses a runtime string and its diagnostic names the array form.ListViewSchema.sortis the producer of exactly those documents:object.list.sortis whatderiveRelatedListsreads. So a view authored withsort: 'created_at desc'validated here, cleanly, and then failed downstream — the contract minting a shape its consumer rejects, with the author told off by the wrong layer. Triage graded p2 on that asymmetry, not on the string. This PR is the producer-side pull-back.Reproduction, re-taken on today's tree
Taken on
origin/mainata9c6477(the card measured against installed 17.3.0; this is the re-take), withbogusProprefused by name on the same call as the firing control:ListViewSchema.safeParsesort: 'name desc'sort—invalid_type, prescription naming the arraysort: '-name'sortsort: [{ field, order }]sort: 42sort/invalid_unionsort/invalid_type, zod's default messagebogusPropThe control fires in both columns, so both the PARSES and the REFUSED readings are verdicts rather than a schema reporting nothing.
Requirement 1 — the census, and it is NOT a silent zero
The narrowing is not free, so the population was measured. The census swept both the TS and JSON spellings of a string-valued
sortacross the whole tree and each hit was read as a structure, never counted as a token.It found three live authored sites on the narrowed slot, all converted here:
examples/app-showcase/src/ui/views/task.view.ts—sort: 'estimate_hours desc'on a shipped list view, carried since objectui#2601 as a deliberate live coverage fixture for the string form.packages/lint/src/showcase-shape.fixtures.ts— the frozen snapshot of that same shipped shape (it tracks the app throughdefineView; its subject, the three nameless form sections, is untouched).skills/objectstack-ui/rules/list-views.md— the published rule taught the clause in two code blocks.⇒ A migration entry is therefore owed, and this PR carries one (below). A silent narrowing would have been refused, correctly.
Lit control — the census could have found one, and an independent instrument agrees. With the legacy string put back on the real shipped showcase view and nothing else changed,
tscreds at exactly that line:Mutation proven landed (
git hash-objecta83cbee → 38a2f9b, occurrence count 0 → 1); restore proven by hash equality back to a83cbee and an emptygit diff HEAD.Read, not grepped — sites deliberately NOT converted. ObjectQL
query.sortand the wirenormalizeSortNodes(different doors, different dialects);packages/spec'sbook/docfield-mapping records whosesort: 'order'is an unrelated key of the same name; and thepackages/lintrule fixtures, which feed the PRE-parse walker and never reach this schema.Requirement 2 — #16553 does NOT cover this, re-measured
Re-read rather than assumed. #16553 is closed as completed, and its title and body bound it to
ComponentPropsMapforobject-grid/object-calendar. Its landed artifact, the semantic entry18.object-block-sort-item-array, names only those two doors and states in its own acceptance criteria thatrecord:related_listis the one deliberate exception —ListViewSchemaappears nowhere in it. The decisive reading is the reproduction above: on today'sorigin/main, with that work already merged,ListViewSchema.sortstill accepted'name desc'. The gap is real and this PR is what closes it.What changed
sortsurvives as a key, one union arm lighter, so this is a value narrowing with noretiredKey()tombstone to hang a prescription on. The surviving array member's ownerrormap carries it, keyed onissue.inputbeing a string — the same shapeview.type's retired'page'value andview.exportOptions' retired'pdf'value already use in this schema. Every other invalid value, and any string reaching a descendant (a misspelledorder, say), keeps zod's default report, so nobody is told a clause they never wrote "was removed".Migration —
list-view-sort-string-clause-to-array, a D2 conversion wired into the protocol-18 chain step, not a semantic TODO: the rewrite is lossless and wholly mechanical.'created_at desc'is the tuple; a bare field name meant ascending and is written out asorder: 'asc'; a comma-separated clause becomes one entry per key, in the same order. A clause that does not parse as that grammar is left alone and meets the door instead — the'-field'dialect belongs toRecordRelatedListProps.sort, never reachesconvertSortToQueryParams, and retiring it was not ruled, so guessing a direction for it would invent an ordering the author never wrote.A real consequence the gates caught. Removing the string arm made the sort entry's own keys visible to the liveness walk, which reported
view/list.sortas an undeclared container inheritance. Drilled with in-repo evidence (normalizeSortNodesreads bothfieldandorder) rather than parked in the shrink-only baseline, so the container-coverage numbers did not grow.Ablation — both directions, with a cost-direction leg
Subject resolves through a same-package relative source import, so no
distleg is in play; on-disk proof is still taken on every leg. Each leg: mutate → prove it landed (occurrence count ANDgit hash-object) → run → restore → prove restored by hash AND emptygit diff HEAD. Baseline blob6053ffc.z.string()union armorderenum loses'desc')Leg 2 is the cost direction on purpose: the price of this change is collateral narrowing of the spelling that has to keep working, and it shows the positive pin catches exactly that.
Verification
pnpm --filter @objectstack/spec test(vitest run --project local)pnpm --filter @objectstack/spec test:repo(vitest run --project repo)pnpm --filter @objectstack/spec check:generatedpnpm --filter @objectstack/spec buildnode scripts/check-skills-token-ratchet.mjsnode scripts/check-nul-bytes.mjsEvery exit code above was captured before any pipe (
cmd > file 2>&1; EXIT=$?), and each gate's own printed verdict line is what is quoted.Regenerated artifacts moved exactly as a value narrowing should: nine doc rows across three
content/docs/references/**files and one react-blocks contract row each lost thestring |arm, and nothing else moved.Clause-② — the two limbs, answered from the regenerated artifacts with a lit control
Declared
noas dispatched, and ⛔ not flipped by this round; the seat sets the final value.packages/spec/api-surface/**andpackages/spec/export-origins/**are byte-identical to the base commita9c6477.packages/spec/authorable-surface/**andauthorable-defaults/**are likewise byte-identical. The diff removes a union arm; it declares no new key anywhere.Lit control, so those zeros are measurements and not a blind instrument. One dummy
export const OsIssue17053LitControl = 1;appended to the same file, then rebuild +gen:api-surface+gen:export-origins, moved both artifacts by exactly one line each and named the symbol:Mutation proven landed by hash (6053ffc → ba113de); source and both artifacts restored and proven back at 6053ffc with a clean tree.
Changeset
@objectstack/spec: minor, graded against this repo's own precedent for an accept-set narrowing — the siblingobject-block-sort-item-arraychangeset tookminorfor the same ruling under the launch-window convention for breaking changes. It carries the ADR-0087 registration marker.Publish surface measured rather than assumed, with controls:
@objectstack/specfiles[]shipsdistandsrc/**/*.zod.ts, so the editedview.zod.tsis literally published ⇒ a changeset is owed.@objectstack/lint— no changeset owed. Itsfiles[]is["dist","README.md","CHANGELOG.md"], and the edited fixture symbolSnapshotTaskViewshas zero occurrences inpackages/lint/dist/, while the positive controlvalidateSortableFieldshas three. The grep fires; the fixture is simply not published.examples/app-showcaseisprivate: true.skills/**is in no package'sfiles[].Skills line budget
skills/objectstack-ui/rules/list-views.md— 306 lines before, 306 after (net 0). Package total over allSKILL.mdfiles: 6134 before, 6134 after (noSKILL.mdwas edited).The binding reading is the token ratchet, which reds where lines do not. The first draft came in at 3154 tokens against a 3011 ceiling (+143). ⛔ The ceiling was not raised and no re-wrap was used as currency: the net increase was paid entirely by deleting content — the retired string examples this change makes wrong. Final reading 3009 / 3011, gate exit 0. The migration prescription therefore lives in the parse error, the changeset and the upgrade guide rather than in the published rule, which is where an upgrading author actually meets it.
维护者速读(草稿)
改了什么 —
ListViewSchema.sort不再接受旧的字符串子句('created_at desc'),只接受{ field, order }[]数组。同批把树内三处仍在写字符串的文档改成数组,并补上一条 D2 迁移条目。为什么改 — objectui 已经裁定「一个拼法,数组」并在 PR #8758 里让消费端运行时拒收字符串。spec 是那些文档的生产者,于是出现最坏的缝:文档在上游通过校验、在下游失败,作者被错误的那一层训斥。分诊原话:契约铸造了一个消费者拒收的形状。p2 判在这个不对称上,⛔ 不判在字符串本身。
风险与代价(含回滚) — 代价是真实的:用旧字符串写的文档停止通过校验。普查测出树内三处(showcase 列表视图、lint 快照、已发布技能),全部已转换,因此这不是静默收窄——D2 条目
list-view-sort-string-clause-to-array可机械重写作者源码,存量行按既有路径重放。RecordRelatedListProps.sort的'-field'方言未被触碰(不同方言、未经裁定),该形状的字符串本条目也拒绝猜测方向。回滚 = revert 本 PR:收窄是纯删除一条 union 分支,无数据迁移、无存量改写,回滚不留残迹。席位意见 — (留空)
你要做的 — 定 Clause-② 终值(本轮实测两肢均为零,见上)。本 PR 触
skills/**⇒ 受管面,按 Prime Directive #14 需人工合并。验收备注
Out-of-scope observations, noted and deliberately NOT filed:
packages/lint'sreadSortKeyskeeps a string arm forsort. It is a pre-parse walker on raw authored stacks, so it is defensive rather than dead, andpackages/lintis a sibling round's declared face. Carrier: whoever next revisits that rule. Noted, not filed.packages/lint/src/showcase-shape.fixtures.tsis the one file in this diff insidepackages/lint, the face declared for An action cannot declare whichbulkActionsdispatch contract its body is written for — bare-string fan-out andexecution: 'aggregate'deliver opposite input shapes through one authoring surface #17319. It is a one-line value change forced bytsc(the snapshot tracks the shipped app throughdefineView), and it does not touch that round's subject. Flagged here so the seat can see the overlap rather than discover it at merge.stack.views[]in all three persisted spellings but notobjects[].listViews.*— the same boundaryview-page-mount-removedstates for itself. An object body carrying a string clause is refused at its own door rather than converted. Stated in the conversion's docblock rather than left to be discovered. Noted, not filed: widening the walk is its own card with its own population measurement.Generated by Claude Code