Skip to content

spec(ui): constrain the object-grid / object-calendar sort value to the SortItem array - #17439

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-16553-componentpropsmap-sort-constraint
Sep 10, 2026
Merged

spec(ui): constrain the object-grid / object-calendar sort value to the SortItem array#17439
os-bill merged 3 commits into
mainfrom
claude/issue-16553-componentpropsmap-sort-constraint

Conversation

@os-bill

@os-bill os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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 an os-dev seat in session session_01MkQhmuuJAVDjmeWNixwDDH.

What changed

ComponentPropsMap['object-grid'].sort and ComponentPropsMap['object-calendar'].sort move from z.unknown() to z.array(SortItemSchema) — the same shared import ElementDataSourceSchema.sort, ListPageSchema.sort and element:record_picker's flat sort shorthand already carry. One shared schema, not a third copy.

Also in the diff: the semantic migration entry object-block-sort-item-array under protocol major 18 (registry.ts is regenerated by gen:migration-registry, never hand-edited between the markers), the regenerated content/docs/references/ui/component.mdx, twelve pins in component.test.ts, and a minor changeset 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 sort spelling platform-wide, the array. Item 4 of that ruling is exactly this PR's subject, verbatim:

ComponentPropsMap for object-calendar and object-grid constrains the sort value 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.ts as it stands on origin/main after 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 returned success: true, while bogusProp on 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.

sort and filter were NOT converged together — measured per block, not inferred by symmetry. Enumerating sort-shaped key declarations in the file rather than searching by spelling, with a fabricated key as the dark control (0 hits):

  • object-gridz.unknown() (the door this PR moves)
  • object-calendarz.unknown() (the door this PR moves)
  • element:record_picker — already z.array(SortItemSchema)
  • record:related_listz.union([z.string(), z.array(...)]), an explicitly declared string arm

object-metric and object-kanban carry filter but declare no sort key at all, so the "four object-* blocks" shape that holds for filter does not hold for sort. The four filter declarations and the two element:* doors were not touched.

record:related_list is deliberately left alone. Its string is the 'field' / '-field' dialect read by RelatedList.normalizeSortSpec; it never reaches convertSortToQueryParams, 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 remaining sort door 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:1457 reads schema.sort; :1844-1851 carries an explicit typeof === 'string' arm putting the clause on $orderby verbatim, beside the array arm.
  • plugin-calendar/src/ObjectCalendar.tsx:431 hands schema.sort to convertSortToQueryParams, whose string arm is still present at core/src/utils/sort-query.ts:66-70.
  • plugin-grid/src/index.tsx:222 has published { name: 'sort', type: 'array' } all along; plugin-calendar/src/index.tsx declares no sort input 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-sha bump carries the retirement in.

In-repo authored sort on these blocks: zero. The two showcase pages that author object-grid (command-center.page.ts, my-work.page.ts) declare no sort, and there is no object-calendar author in the tree. Lit control for the sweep: the same grep shape found 40+ string sort values at OTHER doors (view definitions, ObjectQL query.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 sort declarations were reverted to z.unknown().optional() on disk and the pin file re-run; then restored. Predicted direction before the run: turns red.

  • On-disk proof, before: 2 array-form declarations, 0 ablation markers. After the mutation: 0 array-form declarations, 2 ablation markers, and the worktree blob hash differed from the HEAD blob (1de632a2… to ce92734b…). The mutation reached the file.
  • Result: 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.
  • The four that stayed GREEN are the ones that must: the two "accepts a SortItem[] and echoes it" assertions (z.unknown() echoes an array too) and the two bogusProp controls (key checking was never the missing half). A pin set where every assertion moved would have meant the controls were not controls.
  • Restore verified by state, not by exit code: git diff HEAD empty, and the worktree blob hash equals the HEAD blob hash byte-for-byte (1de632a2b2d8dae1becae00e1f7db8354318404a). The mutation script carried an EXIT INT TERM trap restoring through an absolute path with git 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 of ComponentPropsMap.
  • pnpm exec turbo run typecheck over the four downstream consumers that name ObjectGridProps / 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.
  • The derived gate family from 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 no dist/ 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 full pnpm build.
  • pnpm --filter @objectstack/lint run check:doc-formula-expressions — first run exit 3 for the same reason. Re-run after building @objectstack/formula and @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 #8221 inside the two .describe() strings, which project into content/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:docscontent/docs/references/ui/component.mdx was out of date. Regenerated via gen:schema then gen:docs; now exit 0, 221 generated files in sync.

check:react-declaration-parity was 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's sort sits in "declared by both"; object-calendar's is spec-only, which is correct because that registration declares no sort input.

验收备注

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.

Not flipped ready, not enqueued, no auto-merge armed.


Generated by Claude Code

os-bill and others added 3 commits September 10, 2026 13:02
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5d527f7e177473ee7dafdf00e277b1cded3daf42packageMentionDocs.

Which tree this was computed on

This run read content/docs from 208316ee0c8179f39637b62cd3a398a5c046da59 — the merge of head b19b41eeeca9c11ed1859d4175032e524221c94e into base 5d527f7e177473ee7dafdf00e277b1cded3daf42, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT — undrafted and enqueued. Four pre-checks, with the one that came back adverse explained rather than obeyed

domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T14:49Z.

# pre-check reading
at-tier verdict for this head not owedClause-②: no on a strict narrowing, and objectui#8221's item 4 rules it 「ordinary tier」 in as many words
check-clause2-carriers --pair 17439 exit 4, C5, two T1 tells — both measured FALSE, see below
check-governed-merges --test over the FINAL six paths exit 0 on all six · lit control AGENTS.md exit 3
CI, newest-run-per-check-NAME plus the combined commit status 34 names: 32 success, 2 skipped, 0 failure, 0 pending · combined status success

② is a card, not a hold — and the two tells are false for a reason worth stating

T1 component.zod.ts:2525  + sort: z.array(SortItemSchema).optional()
T1 component.zod.ts:2894  + sort: z.array(SortItemSchema).optional()

T1's own text is "a new key on a Zod object schema". Neither key is new — each + line has a paired - in the same file diff:

-  sort: z.unknown().optional().describe('Initial sort (array of { field, order })'),
-  sort: z.unknown().optional().describe('Sort for the fetched events'),

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 noyes, which would record a widening in a governance ledger that did not happen — ⛔ the declaration was not re-declared and the tell was not suppressed. The pair's declaration limb is satisfied and the only adverse row is a tell measured false, which is a card to file. Filed as evidence on #17300 (now carrying pm:retriage, asking triage whether to split the T1 shape from the graded T2 subject).

⭐ The fix shape is exact and already half-built: check-widening-tells.mjs:773 — the T2 arm — calls rewritesExistingOpener(text, removedByHunk.get(hunk)) (added by #16822) precisely so a re-declared opener is not read as a new member. The T1 arm at :767 tests the added line alone and never consults removedByHunk, which is already computed and in scope.

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:

  • It refused to reuse a lit-control number it could not reproduce. My claim comment cited «lit control visibleWhen reads 7». It does not — the round read 23 in the file it was editing, I read 8 in the file the key actually lives in (page.zod.ts), and no cell is 7. The number was mine and it was wrong; the load-bearing half (app = 0) reproduces exactly. Corrected on the card (5620288858).
  • It declared a widened file face instead of absorbing it. Two paths declared, six landed — all four additions gate-forced or generated. The seat amended the claim; the round did not widen it.
  • Its ablation left four pins deliberately green — the two "echoes a SortItem[]" assertions (z.unknown() echoes an array too) and the two bogusProp controls. A pin set where every assertion moves would have meant the controls were not controls.

⚠️ One correction to the body, recorded here rather than by editing it (a REST PATCH /pulls appends a second attribution footer): its closing line "Not flipped ready, not enqueued, no auto-merge armed" was true when written and is now superseded by this comment.

⚠️ Carried forward for the merge: content/docs/references/** is routed merge=os-regen in .gitattributes (line 153), so component.mdx sits on the regen driver's path — the driver that can exit 0 while silently dropping one side. Containment is the queue re-running check:docs on the merged ref. ⭐ Not hypothetical: that driver dropped a side on a different path in PR #17334 today, exit 0 and no conflict marker.

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

Merged via the queue into main with commit 9788f1e Sep 10, 2026
36 checks passed
@os-bill
os-bill deleted the claude/issue-16553-componentpropsmap-sort-constraint branch September 10, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/m tests tooling

Projects

None yet

1 participant