Repository navigation
fix(lint)!: refuse a stored sort on a sortable: false field, on list views and every stored page sort - #30084
Conversation
…t views and every stored page sort validate-sortable-fields judged only the virtual-type predicate, so a list view whose sort named a field the author declared sortable: false passed os validate and the view door, then answered 400 INVALID_SORT on every load once the list door honoured the key. No page sort was judged for sortability at all. The rule now reads resolveObjectSortability (the projection the list door refuses by) for both reasons, and walks every stored page sort: element dataSource.sort, a related list's parentDataSource.sort, the own sort of each block that declares one (pinned against ComponentPropsMap), interfaceConfig.sort and an app page's record.sort. The reference-integrity suite now dispatches on page writes for this one member. The field-sortable-false-sort-refused entry lists every stored surface. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgUAaThh7rNSXWuzXR
…he entry checkSortDeclaration's new optional sortability argument is built by indexObjectSortability, so an out-of-repo caller of the exported core can pass it. The changeset's widening clause names the two exports. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgUAaThh7rNSXWuzXR
📓 Docs Drift CheckThis PR changes 2 package(s): 38 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 170 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 497170c2e0c081b9595f6ace8a9542edd7b04512 && git checkout 497170c2e0c081b9595f6ace8a9542edd7b04512
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d79eecfe5b78598f9117726b542615ca2332d58f c745679c608b987d8810d2b3a2c4b27e03991d62 && git checkout -B drift-repro d79eecfe5b78598f9117726b542615ca2332d58f && git merge --no-ff c745679c608b987d8810d2b3a2c4b27e03991d62
node scripts/docs-audit/affected-docs.mjs --json d79eecfe5b78598f9117726b542615ca2332d58f
|
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 Contract reviewServed-tier: Face: a narrowing of the publish gate. ① Derived judgments
② Semver level
③ Boundary flags
④ Narrowing face
Implemented-by: VERDICT: PASS |
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 席位核对Verdict: ACCEPT at head
|
Requested by Yibo Wang · project thread
Fixes #30023
维护者速读
validate-sortable-fields不再自己判断「虚拟类型」,改为读数据接口列表门所用的同一个可排序投影resolveObjectSortability,所以字段写了sortable: false时,列表视图的sort报sort-field-unsortable(error);同时把规则扩展到页面上所有会打到列表门的已存排序(区块自身sort、元素dataSource.sort、相关列表parentDataSource.sort、列表页interfaceConfig.sort、应用页record.sort),并让 page 写入门也跑这条规则。INVALID_SORT,但os validate和发布门照样放行,于是视图首次加载起每次都 400;页面上的排序此前完全不判可排序性(公式字段也一样)。@objectstack/lint和@objectstack/specminor):带这种排序的视图或页面在下次 validate / 保存时被拒(422),已存的照常加载。仓内语料没有任何sortable: false,示例栈实测 0 条新发现。回滚即 revert 本 PR。packages/lint/src/validate-sortable-fields.ts的unsortableFinding、validatePageSorts、PAGE_BLOCK_SORT_OBJECT_PROP;reference-integrity-suite.ts的page跨入。pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.runtime-authoring-gate.test.ts -t 30023(先建 lint),预期 7 个用例全过:视图和 4 个页面槽位被拒在各自路径,对照(无键、true、公式字段保留自己的判定)照常。Clause-②: yes (narrowing) —
sort-field-unsortable(error) now also fires on a sort naming a field whose author declaredsortable: false, on the list-view rungs it already walked and on every stored page sort it did not walk before (a block's ownsort, every elementdataSource.sort, a related list'sparentDataSource.sort, a list page'sinterfaceConfig.sort, an app page'srecord.sort, where aformulasort is now refused too), soos validate/os build/os lintand thepageandviewpublish doors refuse metadata they used to accept. The widening half:@objectstack/lintexportsindexObjectSortabilityand theSortabilityFieldstype, andcheckSortDeclarationtakes an optional trailingsortabilityindex.Metadata-protocol: narrowed
Skills-docs: none —
skills/**names nosortablefield key and no page or viewsortrule;content/docs/**already teachessortable: falseas "do not allow sorting by this field". The prescription ships in the finding's fix line,os explain sort-field-unsortableand the upgrade-guide entry.What changes
One projection, two reasons (
packages/lint/src/validate-sortable-fields.ts). The virtuality check no longer callsisVirtualSearchFielditself. It reads the projectionresolveObjectSortability(@objectstack/spec/api) builds from the authored object, the resolver the list door (assertSortFieldsExist) refuses by. Asortable: falseentry withreason: 'virtual-type'keeps the existing message byte for byte. One withreason: 'author-declared'reportssort-field-unsortableaterrorwith the door's clause:list-view sort orders by "body", a field on object "help_ticket" that declaressortable: false, so every load answers 400 INVALID_SORT. The fix line isSort by another field, or removesortable: falsefrom "body" to make it sortable. Only the literalfalsecounts. A formula field that also declaressortable: falsekeeps its virtual verdict, because the projection does.Grading.
error, the level the A view's declaredsortnaming aformulafield clears lint, then 400s on every load — the SORT axis has no authoring gate, unlike SEARCH (#6674) #9257 precedent (formula) publishes at. The door answers both reasons with the same400 INVALID_SORTon the view's first fetch.The exported core stays compatible.
checkSortDeclarationtakes an optional trailingsortabilityindex (indexObjectSortability, exported). If a caller omits it, the projection comes from the type-only slice, so the caller gets the pre-change answers (virtual only). This is the posture theunprovisionedAnchorsparameter already has. Every in-repo caller passes it.Every stored page sort is walked (
validatePageSorts), for the projection's verdict only:each component's
dataSource.sort, bound todataSource.object;a
record:related_list'sproperties.parentDataSource.sort, bound to its ownobject;the
properties.sortof each block that declares one, bound todataSource.objectand then the block's object prop. ThePAGE_BLOCK_SORT_OBJECT_PROPtable is pinned againstComponentPropsMap:list-viewdata.objectrecord:related_listobjectNamerecord:line_itemschildObjectobject-grid,object-calendar,object-gantt,object-map,object-timelineobjectNamea list page's
interfaceConfig.sort, bound tosourceand then the pageobject;an app page's
record.sort, bound torecord.object.validate-page-field-bindingsstill owns existence and provenance on these positions, aspage-field-unknown/page-field-unprovisioned. This rule adds only the question that rule does not ask, so no name is reported twice.The page door (
reference-integrity-suite.ts,authoring-rules.ts).validateSortableFieldsdeclarespage, and the suite entry'sruntimeTypesgainspage. A page write reaches exactly this one member: no other member declarespage. The member resolves only againststack.objects, which the per-write page snapshot carries. That is the [finding] Both field-resolution gates for a list view (searchableFieldsandsort) are CLI-only — the reference-integrity suite dispatches at the runtime publish gate forflowalone, so a Studio/MCPviewwrite gets neither #9313 reason, one write type over. The page dispatch pin (runtime-gate.page-visibility-predicate-writes.test.ts) listsvalidateReferenceIntegrity.os explain sort-field-unsortable(rule-explanations.ts) gains the author-declared paragraph and the page surfaces.Upgrade-guide entry
18.field-sortable-false-sort-refused:surfaceandacceptanceCriterianow list every stored surface above (delta review 6625831334 ③). Thereasonsentence "Lint does not yet judge a viewsort…" is replaced. It now says thatos validateand the publish door refuse each surface.Changeset:
@objectstack/lintand@objectstack/specare bothminor, withMetadata-protocol: narrowed, the BREAKING banner, and Before / Now / Not changed / The remedy. The ADR-0087 marker isnot-required (already-registered field-sortable-false-sort-refused), which cites the existing entry. A Supersedes, in the same release paragraph corrects field.sortable: false has no effect — the sortability projection still reports the field sortable, the data door sorts by it, and the list header and Sort picker offer it #29896's unreleased note "Lint does not yet judge these at authoring." Before writing it I checked that npm@objectstack/spec@18.3.0(published 2026-11-03 15:15Z) does not carryfield-sortable-false-sort-refused.Why
#30021 (
140d8ce8) makes the list door refuse a sort that names asortable: falsefield. The lint rule keyed only onisVirtualSearchField, soos validateand the view door both passed such a view. On a page, none of the stored sorts was judged for sortability at all. The rule walked only the five list-view rungs, and its module note left page sorts towalkPageComponents' territory, where only existence was asked. The view then broke on every load, traced to nothing.Readings
Surfaces before this PR:
objects[].listViews.*.sort,views[].list.sort,views[].listViews.*.sort, the flattened overlayviews[].sortand the recordviews[].config.sort, all for the virtual verdict only.pagewrites.runtimeAuthoringRulesFor('page')listed novalidateReferenceIntegrity.Surfaces the delta review missed, found by enumerating every
sort:key underpackages/spec/src/**/*.zod.tsand by the new drift pin:record:line_itemssort;list-viewnode's ownsort(ListViewPagePropsspreadsListViewSchema);parentDataSource.sort.The drift pin first went red naming
list-view, which the hand list had missed.App page
record.sortreaches the door.ui/packages/app-shell/src/views/PageView.tsx#appPageRecordBindingmounts it throughElementDataSourceGatewithsort: true(objectui#12931). Itsliveness/page.jsonrow still saysplanned, which is stale. That is noted below and not changed here.Corpus. No shipped object declares
sortable: false:grep -rn "sortable: false"overexamples/,packages/platform-objects/srcandpackages/pluginsfinds nothing.validateSortableFieldsoverexamples/app-crm(6 objects, 1 page),app-todoandapp-multi-packagegives 0 findings.examples/app-showcase(24 objects, 29 pages): 0 findings. Non-vacuity probe: with every authored field set tosortable: falsein memory, the same run fires 3 findings, 2 of them on pages (pages[14].interfaceConfig.sort[0],pages[17].interfaceConfig.sort[0]), so the page walk reaches the shipped corpus.Tests
packages/lint/src/validate-sortable-fields.test.ts(+16):errorlevel. It fires on the legacy string form, on-field, and on the overlay and record view shapes.true, and a formula field withsortable: false, which keeps the virtual message.resolveObjectSortability(object).fields.sort, itsdataSource.sortandparentDataSource.sort,object-grid,record:line_items,list-view, an elementdataSource.sort,interfaceConfig.sortandrecord.sort.dataSource.objectwins over the block prop. A list page with nosourcebinds to the pageobject.true, an unknown name, a dotted path,created_at, another package's object and a block with nosortprop are all not reported. Neither is akind: 'react'page's cached regions.PAGE_BLOCK_SORT_OBJECT_PROP↔ComponentPropsMap: every row with a livesortkey, each with a live object prop. The tombstone detector is pinned non-vacuous onelement:record_picker.sort.packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, a newdescribeon the existing stub-engine harness, so no double ledger moves; +7):PUTview withsort: bodygives 422INVALID_METADATAwithsort-field-unsortableatviews[0].sort[0], carrying the door's clause, and stores nothing.bodyat arecord:related_listsort, an elementdataSource.sort, a list page'sinterfaceConfig.sortand an app page'srecord.sortgives 422 atpages[0]…slot paths, and stores nothing.subject(no key) andpriority(sortable: true) save on the view and on all four page slots.score(formula) is refused with its own virtual message on the view and on a page.Verification
Readings are at
90989939on basec23501b2, unless marked otherwise. Headc745679cadds only theindex.tsexport ofindexObjectSortability/SortabilityFieldsand the matching changeset clause. For that head:lint
tsc,check:test-typecheckand the four touched lint test files (90 tests, includingrule-id-barrel-exports) were rerun.check:dts-closure,check:lean-entry-closure,check:published-files,check:undeclared-dep-imports,check:api-surface,check-changeset-no-major,check-empty-changesetandcheck-adr-0087-registrationwere rerun, all exit 0.The
--commandsderivation is unchanged: the same 82.Builds. A full
turbo run build --filter='!@objectstack/docs' --concurrency=2ran under the verify lock: 119 tasks, 0 failed, 11m16s on a shared box.Package suites.
@objectstack/linttest: 193 files, 7988 tests passed.typecheck: 0.@objectstack/spectest: 811 files, 24387 tests passed.typecheck: 0.@objectstack/metadata-protocoltest: 312 files, 22354 tests passed, 26 skipped.typecheck: 0.pnpm lint(repo eslint): 0.Derived gates.
dispatch-gates --commandsderived 82, and all 82 ran with exit 0.dispatch-gates --ranreports 82 run, 0 NOT-MEASURED, 0 UNRUN; the record is/mnt/project-files/issue-30023/gates-ran.list.check-plugin-teardown-shape --self-testfirst exited 3 because this shallow clone could not reach its pinned fixture. Aftergit fetch --unshallow origin mainit exited 0, and 48 cases passed.check-adr-0087-registration --base origin/main:not-required (already-registered)accepted.check-changeset-no-majorwith this body as the event: LEVEL AXISyes (narrowing)+narrowed, and no moved package is gradedpatch.test:repowas not run locally. It belongs to CI (Test Core).Ablation A, at the author-declared verdict. I changed
unsortableFinding's guard to also return forreason === 'author-declared', rebuilt lint and reran both files:validate-sortable-fields.test.ts:Tests 7 failed | 57 passed (64). The 7 are the author-declared and page-slot cases. Every control, and the formula page case, stayed green.Tests 5 failed | 2 passed. The view and the 4 page slots went red; the 2 controls stayed green.Ablation B, at the page-door crossing. I dropped
'page'fromvalidateSortableFields' memberruntimeTypes:64 passed.Tests 5 failed | 2 passed: the 4 page slots and the virtual page control. The view refusal and the save control stayed green.Acceptance notes
kind: 'react'page's JSX props (validate-react-page-props' territory, unchanged), dashboard widgetsortByand reportorder[].by(dataset names, not object fields; as before).sortreaches the same door and answers 400, yetpage-field-unknownreports it atwarning. That is a pre-existing severity call invalidate-page-field-bindings, and this PR leaves it unchanged. Reported to the seat; not filed here.sortable: falseon a field and a built-inlistViews.*.sorton it is judged byos validate, and by the view door for view items, but not by the object publish door.validateSortableFieldsdoes not declareobject, andreference-integrity-suite.tsrecords that crossing as its own adjudication ([P0] Studio package publish accepts a danglinghighlightFieldsreference — the authoring gate never sees what the app builder produces #15254's note). Reported to the seat.liveness/page.jsonrecordstill saysplanned, whileui/…/PageView.tsxreads the binding (objectui#12931). Not changed here.🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzgUAaThh7rNSXWuzXR