spec(ui): constrain the object-grid / object-calendar sort value to the SortItem array - #17439
Conversation
…tem array `ComponentPropsMap` declared `sort` as `z.unknown()` on both blocks, so an array, the legacy string clause and a bare number all returned `success: true` while `bogusProp` was refused by name on the same call. objectui#8221 decision batch #77 (2026-09-07, option B) rules one `sort` spelling platform-wide — the array — and its item 4 names these two doors as the spec-side half. Both now declare `z.array(SortItemSchema)`, the shared schema `ElementDataSourceSchema.sort`, `ListPageSchema.sort` and `element:record_picker` already import. `record:related_list`'s string arm is deliberately untouched: it is the `'field'` / `'-field'` dialect read by `RelatedList.normalizeSortSpec`, never reaching `convertSortToQueryParams`, and retiring it was not ruled. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…the changeset Pins on both doors: the SortItem array is accepted and echoed; the legacy string clause and a bare number are refused at `sort` (`invalid_type`, expected array); a misspelled or absent direction at `sort.0.order`; `bogusProp` is still refused by name on the same call (the control the card keeps). A census over the whole map pins that `record:related_list` is the ONE `sort` door still taking a string, and why that is deliberate. Registers the semantic migration `object-block-sort-item-array` under protocol major 18 (registry.ts is regenerated by `gen:migration-registry`, never edited) and declares the breaking accept-set change as `minor` per the launch-window convention. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…out of customer text `check:doc-authoring` refuses an internal issue id in `.describe()` prose — it projects into `content/docs/references/**` and the generated skill artifacts, where `#NNNN` resolves to nothing. Both `sort` describes now cite the migration id instead, which a reader can act on. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 135 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 208316ee0c8179f39637b62cd3a398a5c046da59 && git checkout 208316ee0c8179f39637b62cd3a398a5c046da59
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5d527f7e177473ee7dafdf00e277b1cded3daf42 b19b41eeeca9c11ed1859d4175032e524221c94e && git checkout -B drift-repro 5d527f7e177473ee7dafdf00e277b1cded3daf42 && git merge --no-ff b19b41eeeca9c11ed1859d4175032e524221c94e
node scripts/docs-audit/affected-docs.mjs --json 5d527f7e177473ee7dafdf00e277b1cded3daf42 |
ACCEPT — undrafted and enqueued. Four pre-checks, with the one that came back adverse explained rather than obeyed
② is a card, not a hold — and the two tells are false for a reason worth statingT1's own text is "a new key on a Zod object schema". Neither key is new — each A re-declaration, and one whose accept set strictly shrinks. Per the standing C5 ruling — a false positive is repaired in the matcher, ⛔ not paid for by the author; the only word that clears a false tell is ⭐ The fix shape is exact and already half-built: On the round itself⭐ Three things this round did that are the behaviour being asked for, and each is worth naming because the opposite is the cheap path:
Next on this file: #17166 is queued behind this PR (serial — it lands two lines away) with its potholes recorded on the card. Generated by Claude Code |
Fixes #16553
Clause-②: no — re-declared on the measured diff, not inherited from the dispatch.
z.unknown()accepted every value;z.array(SortItemSchema)accepts a strict subset of them, so the accept set only shrinks. Nothing that parsed before this PR and is still authorable is newly refused by anything other than the ruled narrowing itself, and no value starts being accepted. Implemented by anos-devseat in sessionsession_01MkQhmuuJAVDjmeWNixwDDH.What changed
ComponentPropsMap['object-grid'].sortandComponentPropsMap['object-calendar'].sortmove fromz.unknown()toz.array(SortItemSchema)— the same shared importElementDataSourceSchema.sort,ListPageSchema.sortandelement:record_picker's flatsortshorthand already carry. One shared schema, not a third copy.Also in the diff: the semantic migration entry
object-block-sort-item-arrayunder protocol major 18 (registry.tsis regenerated bygen:migration-registry, never hand-edited between the markers), the regeneratedcontent/docs/references/ui/component.mdx, twelve pins incomponent.test.ts, and aminorchangeset carrying the**BREAKING**accept-set declaration and the ADR-0087 disposition marker.The ruling this implements
objectui#8221, decision batch #77, 2026-09-07, maintainer verbatim 「其他同意」, option B: one
sortspelling platform-wide, the array. Item 4 of that ruling is exactly this PR's subject, verbatim:「
ComponentPropsMapforobject-calendarandobject-gridconstrains thesortvalue to the array shape (today it accepts anything), so the spec, the registrations and the helper agree; that is a pull-back to the declared contract, ordinary tier」What was measured, and what each probe matched
Every reading below was taken on this branch's own head, at
packages/spec/src/ui/component.zod.tsas it stands onorigin/mainafter PR #17342 and PR #17257 — no earlier reading of that file was reused.The card's premise holds. Probing the two doors through the real map: an array, the legacy string clause
'created_at desc'and a bare number all returnedsuccess: true, whilebogusPropon the same call was refused by name (unrecognized_keys,keys: ['bogusProp']). So key checking was live and only the VALUE was unheld — the control that makes the three positive readings verdicts rather than a schema reporting nothing.sortandfilterwere NOT converged together — measured per block, not inferred by symmetry. Enumeratingsort-shaped key declarations in the file rather than searching by spelling, with a fabricated key as the dark control (0 hits):object-grid—z.unknown()(the door this PR moves)object-calendar—z.unknown()(the door this PR moves)element:record_picker— alreadyz.array(SortItemSchema)record:related_list—z.union([z.string(), z.array(...)]), an explicitly declared string armobject-metricandobject-kanbancarryfilterbut declare nosortkey at all, so the "fourobject-*blocks" shape that holds forfilterdoes not hold forsort. The fourfilterdeclarations and the twoelement:*doors were not touched.record:related_listis deliberately left alone. Its string is the'field'/'-field'dialect read byRelatedList.normalizeSortSpec; it never reachesconvertSortToQueryParams, and retiring it was not ruled. objectui#8221's own implementing round narrowed it, established the dialect, and reverted the narrowing byte-identically for that reason. The census pin in this PR asserts it is the ONE remainingsortdoor that takes a string, so the exception is pinned rather than tolerated.Sequenced measurement-first, and the sequence is stated rather than glossed. At the objectui pin this repo builds against (
53ded82b) the legacy string is STILL lowered:plugin-grid/src/ObjectGrid.tsx:1457readsschema.sort;:1844-1851carries an explicittypeof === 'string'arm putting the clause on$orderbyverbatim, beside the array arm.plugin-calendar/src/ObjectCalendar.tsx:431handsschema.sorttoconvertSortToQueryParams, whose string arm is still present atcore/src/utils/sort-query.ts:66-70.plugin-grid/src/index.tsx:222has published{ name: 'sort', type: 'array' }all along;plugin-calendar/src/index.tsxdeclares nosortinput at all.So this declaration lands AHEAD of the pinned consumer. The ruling permits that explicitly — either order, since the registrations already declare the array — and objectui PR #8758 (merged 2026-09-09, after this pin) drops the string arm from the helper. The next
.objectui-shabump carries the retirement in.In-repo authored
sorton these blocks: zero. The two showcase pages that authorobject-grid(command-center.page.ts,my-work.page.ts) declare nosort, and there is noobject-calendarauthor in the tree. Lit control for the sweep: the same grep shape found 40+ stringsortvalues at OTHER doors (view definitions, ObjectQLquery.sort, lint fixtures) — none of them a block prop, none of them touched. So nothing in-tree needed converting, and the migration entry carries the prescription for authors outside the repo.Reverse verification (ablation)
Both
sortdeclarations were reverted toz.unknown().optional()on disk and the pin file re-run; then restored. Predicted direction before the run: turns red.1de632a2…toce92734b…). The mutation reached the file.Tests 8 failed | 272 passed (280), exit 1. The eight are the string refusal, the bare-number refusal, the SortItemSchema-shape checks, the cross-door agreement and the census — on both doors.z.unknown()echoes an array too) and the twobogusPropcontrols (key checking was never the missing half). A pin set where every assertion moved would have meant the controls were not controls.git diff HEADempty, and the worktree blob hash equals the HEAD blob hash byte-for-byte (1de632a2b2d8dae1becae00e1f7db8354318404a). The mutation script carried anEXIT INT TERMtrap restoring through an absolute path withgit checkout HEAD --, and treated an empty hash as a failure.Tests and gates
Run at head
b19b41eeec. Exit codes captured before any pipe.pnpm --filter @objectstack/spec build— exit 0 (twice: once before the registry change, once after).pnpm --filter @objectstack/spec typecheck— exit 0 (tsc --noEmit,check:scripts-typecheck,check:test-typecheck; the test layer compiles, 54 files / 261 errors / 145 pinned signatures held).pnpm --filter @objectstack/spec test— exit 0,Test Files 468 passed (468),Tests 13202 passed (13202).pnpm --filter @objectstack/lint test— exit 0,Test Files 103 passed (103),Tests 3717 passed (3717). The closest real consumer ofComponentPropsMap.pnpm exec turbo run typecheckover the four downstream consumers that nameObjectGridProps/ObjectCalendarProps/ComponentPropsMap(@objectstack/lint,@objectstack/mcp,@objectstack/platform-objects,@objectstack/example-showcase) — exit 0,Tasks: 65 successful, 65 total. The narrowed published type breaks no consumer.pnpm exec eslint . --no-inline-config --format json— exit 0 over the WHOLE population, 6553 files linted, 0 errors, 0 warnings. Not a narrowed run: the repo-wide scan completed, so no narrowing argument is needed. Reading taken at this head.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(re-derived after the changeset existed): 85 commands, all run. 83 exit 0. The two that did not are named below.Two results that are NOT green and NOT red:
pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET: it reads built output and eight packages have nodist/in this worktree. Its own text says "This is NOT a pass: nothing was measured." NOT MEASURED locally, declared to CI, which runs it after a fullpnpm build.pnpm --filter @objectstack/lint run check:doc-formula-expressions— first run exit 3 for the same reason. Re-run after building@objectstack/formulaand@objectstack/lint: exit 0. Reported as the second reading.Two gates were red on a first run and are green now, both fixed in this diff rather than argued with:
check:doc-authoring— refused#8221inside the two.describe()strings, which project intocontent/docs/references/**and the generated skill artifacts where an issue id resolves to nothing. Both describes now cite the migration id, which a reader can act on. Now exit 0 (15022 customer-facing strings clean).check:docs—content/docs/references/ui/component.mdxwas out of date. Regenerated viagen:schemathengen:docs; now exit 0, 221 generated files in sync.check:react-declaration-paritywas run as CI runs it, with the checked-in root manifest —MANIFEST="$PWD/sdui.manifest.json" pnpm --filter @objectstack/spec check:react-declaration-parity --baseline react-declaration-parity.baseline.json --strict— exit 0, no new declaration divergence versus the accepted baseline.object-grid'ssortsits in "declared by both";object-calendar's is spec-only, which is correct because that registration declares nosortinput.验收备注
Observations found in passing. None is filed as a card — none meets the (a)/(b)/(c) bar, and each is recorded here with the carrier that will meet it.
component.zod.ts'selement:record_pickerfilterdocblock still says "the fourobject-*blocks declarefilterasz.unknown(), no orthography at all". finding(spec): fourobject-*filterdoors inComponentPropsMapdeclarez.unknown()— no orthography at all — so they still accept the MongoDB-style record ui#6206-B retired, silently; the card that closed the lastFilterConditionSchemadoor assumed they declared the array #15449 falsified that: all four now declarez.array(ViewFilterRuleSchema). A stale comment, not a contract or code defect, and out of this card's defect class — untouched here. Carrier: the next PR that moves afilterdoor in this map, which will read that paragraph.SortItemSchemarequiresorder, whileconvertSortToQueryParamsdeliberately treats an omittedorderas ascending (its docblock states this as a fidelity improvement over the private copies it replaced). So the spec refuses[{ field: 'x' }]at everysortdoor while the pinned consumer would have honoured it. The refusal is LOUD, not a silent drop, and the spec is the contract, so this is downstream tolerance rather than a defect here. Carrier: whoever next revisitsSortItemSchema's optionality.object-gridstill honours the retired string spelling at runtime through the grid's private lowering, which that PR's ruling did not name. Already public on objectui#8221 and already judged there to be a separate card. Carrier: that thread; nothing to add from this side..objectui-sha(53ded82b) predates objectui PR fix(security): controlled_by_parent master-editability consults the same authored write widener the by-id path does (#8679) #8758, so the pinned consumer and this declaration disagree about the string for exactly as long as the pin sits where it is. That is the ruled sequence, not a defect. Carrier: the seat that next runsobjectui:refreshandpnpm sdui:manifest.Not flipped ready, not enqueued, no auto-merge armed.
Generated by Claude Code