Skip to content

fix(lint)!: refuse a stored sort on a sortable: false field, on list views and every stored page sort - #30084

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-vyggze
Nov 4, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-vyggze

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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 写入门也跑这条规则。
  • 为什么改:fix(spec,metadata-protocol)!: honour field.sortable false in the sortability projection and the data door #30021 之后列表门对这种排序答 400 INVALID_SORT,但 os validate 和发布门照样放行,于是视图首次加载起每次都 400;页面上的排序此前完全不判可排序性(公式字段也一样)。
  • 风险与回滚:接受集收窄(BREAKING,Metadata-protocol narrowed,@objectstack/lint 和 @objectstack/spec minor):带这种排序的视图或页面在下次 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 declared sortable: false, on the list-view rungs it already walked and on every stored page sort it did not walk before (a block's own sort, every element dataSource.sort, a related list's parentDataSource.sort, a list page's interfaceConfig.sort, an app page's record.sort, where a formula sort is now refused too), so os validate / os build / os lint and the page and view publish doors refuse metadata they used to accept. The widening half: @objectstack/lint exports indexObjectSortability and the SortabilityFields type, and checkSortDeclaration takes an optional trailing sortability index.
Metadata-protocol: narrowed
Skills-docs: none — skills/** names no sortable field key and no page or view sort rule; content/docs/** already teaches sortable: false as "do not allow sorting by this field". The prescription ships in the finding's fix line, os explain sort-field-unsortable and the upgrade-guide entry.

What changes

  • One projection, two reasons (packages/lint/src/validate-sortable-fields.ts). The virtuality check no longer calls isVirtualSearchField itself. It reads the projection resolveObjectSortability (@objectstack/spec/api) builds from the authored object, the resolver the list door (assertSortFieldsExist) refuses by. A sortable: false entry with reason: 'virtual-type' keeps the existing message byte for byte. One with reason: 'author-declared' reports sort-field-unsortable at error with the door's clause: list-view sort orders by "body", a field on object "help_ticket" that declares sortable: false, so every load answers 400 INVALID_SORT. The fix line is Sort by another field, or remove sortable: false from "body" to make it sortable. Only the literal false counts. A formula field that also declares sortable: false keeps its virtual verdict, because the projection does.

  • Grading. error, the level the A view's declared sort naming a formula field 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 same 400 INVALID_SORT on the view's first fetch.

  • The exported core stays compatible. checkSortDeclaration takes an optional trailing sortability index (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 the unprovisionedAnchors parameter 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 to dataSource.object;

    • a record:related_list's properties.parentDataSource.sort, bound to its own object;

    • the properties.sort of each block that declares one, bound to dataSource.object and then the block's object prop. The PAGE_BLOCK_SORT_OBJECT_PROP table is pinned against ComponentPropsMap:

      Block Object prop
      list-view data.object
      record:related_list objectName
      record:line_items childObject
      object-grid, object-calendar, object-gantt, object-map, object-timeline objectName
    • a list page's interfaceConfig.sort, bound to source and then the page object;

    • an app page's record.sort, bound to record.object.

    validate-page-field-bindings still owns existence and provenance on these positions, as page-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). validateSortableFields declares page, and the suite entry's runtimeTypes gains page. A page write reaches exactly this one member: no other member declares page. The member resolves only against stack.objects, which the per-write page snapshot carries. That is the [finding] Both field-resolution gates for a list view (searchableFields and sort) are CLI-only — the reference-integrity suite dispatches at the runtime publish gate for flow alone, so a Studio/MCP view write gets neither #9313 reason, one write type over. The page dispatch pin (runtime-gate.page-visibility-predicate-writes.test.ts) lists validateReferenceIntegrity.

  • 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: surface and acceptanceCriteria now list every stored surface above (delta review 6625831334 ③). The reason sentence "Lint does not yet judge a view sort…" is replaced. It now says that os validate and the publish door refuse each surface.

  • Changeset: @objectstack/lint and @objectstack/spec are both minor, with Metadata-protocol: narrowed, the BREAKING banner, and Before / Now / Not changed / The remedy. The ADR-0087 marker is not-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 carry field-sortable-false-sort-refused.

Why

#30021 (140d8ce8) makes the list door refuse a sort that names a sortable: false field. The lint rule keyed only on isVirtualSearchField, so os validate and 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 to walkPageComponents' territory, where only existence was asked. The view then broke on every load, traced to nothing.

Readings

  • Surfaces before this PR:

    • The rule walked objects[].listViews.*.sort, views[].list.sort, views[].listViews.*.sort, the flattened overlay views[].sort and the record views[].config.sort, all for the virtual verdict only.
    • It walked no page surface.
    • The suite did not dispatch on page writes. runtimeAuthoringRulesFor('page') listed no validateReferenceIntegrity.
  • Surfaces the delta review missed, found by enumerating every sort: key under packages/spec/src/**/*.zod.ts and by the new drift pin:

    • record:line_items sort;
    • the page list-view node's own sort (ListViewPageProps spreads ListViewSchema);
    • the related list's parentDataSource.sort.

    The drift pin first went red naming list-view, which the hand list had missed.

  • App page record.sort reaches the door. ui/packages/app-shell/src/views/PageView.tsx#appPageRecordBinding mounts it through ElementDataSourceGate with sort: true (objectui#12931). Its liveness/page.json row still says planned, which is stale. That is noted below and not changed here.

  • Corpus. No shipped object declares sortable: false: grep -rn "sortable: false" over examples/, packages/platform-objects/src and packages/plugins finds nothing. validateSortableFields over examples/app-crm (6 objects, 1 page), app-todo and app-multi-package gives 0 findings. examples/app-showcase (24 objects, 29 pages): 0 findings. Non-vacuity probe: with every authored field set to sortable: false in 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):
    • The author-declared verdict: exact message and fix, path, and error level. It fires on the legacy string form, on -field, and on the overlay and record view shapes.
    • Controls: no key, true, and a formula field with sortable: false, which keeps the virtual message.
    • Projection identity: the rule's index deep-equals resolveObjectSortability(object).fields.
    • The exported core's optional parameter: absent, it answers only the virtual verdict; present, it answers both.
    • Every page slot is refused at its path: related list sort, its dataSource.sort and parentDataSource.sort, object-grid, record:line_items, list-view, an element dataSource.sort, interfaceConfig.sort and record.sort.
    • Binding precedence: dataSource.object wins over the block prop. A list page with no source binds to the page object.
    • A formula sort on a page is refused.
    • Page controls: no key, true, an unknown name, a dotted path, created_at, another package's object and a block with no sort prop are all not reported. Neither is a kind: 'react' page's cached regions.
    • PAGE_BLOCK_SORT_OBJECT_PROP ↔ ComponentPropsMap: every row with a live sort key, each with a live object prop. The tombstone detector is pinned non-vacuous on element:record_picker.sort.
  • The real publish route (packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, a new describe on the existing stub-engine harness, so no double ledger moves; +7):
    • PUT view with sort: body gives 422 INVALID_METADATA with sort-field-unsortable at views[0].sort[0], carrying the door's clause, and stores nothing.
    • A page sorting by body at a record:related_list sort, an element dataSource.sort, a list page's interfaceConfig.sort and an app page's record.sort gives 422 at pages[0]… slot paths, and stores nothing.
    • Controls: subject (no key) and priority (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 90989939 on base c23501b2, unless marked otherwise. Head c745679c adds only the index.ts export of indexObjectSortability / SortabilityFields and the matching changeset clause. For that head:

  • lint tsc, check:test-typecheck and the four touched lint test files (90 tests, including rule-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-changeset and check-adr-0087-registration were rerun, all exit 0.

  • The --commands derivation is unchanged: the same 82.

  • Builds. A full turbo run build --filter='!@objectstack/docs' --concurrency=2 ran under the verify lock: 119 tasks, 0 failed, 11m16s on a shared box.

  • Package suites.

    • @objectstack/lint test: 193 files, 7988 tests passed. typecheck: 0.
    • @objectstack/spec test: 811 files, 24387 tests passed. typecheck: 0.
    • @objectstack/metadata-protocol test: 312 files, 22354 tests passed, 26 skipped. typecheck: 0.
    • pnpm lint (repo eslint): 0.
  • Derived gates. dispatch-gates --commands derived 82, and all 82 ran with exit 0. dispatch-gates --ran reports 82 run, 0 NOT-MEASURED, 0 UNRUN; the record is /mnt/project-files/issue-30023/gates-ran.list.

    • check-plugin-teardown-shape --self-test first exited 3 because this shallow clone could not reach its pinned fixture. After git fetch --unshallow origin main it exited 0, and 48 cases passed.
    • check-adr-0087-registration --base origin/main: not-required (already-registered) accepted.
    • check-changeset-no-major with this body as the event: LEVEL AXIS yes (narrowing) + narrowed, and no moved package is graded patch.
  • test:repo was not run locally. It belongs to CI (Test Core).

  • Ablation A, at the author-declared verdict. I changed unsortableFinding's guard to also return for reason === '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.
    • The route pins: Tests 5 failed | 2 passed. The view and the 4 page slots went red; the 2 controls stayed green.
    • Restored: the blob equals HEAD.
  • Ablation B, at the page-door crossing. I dropped 'page' from validateSortableFields' member runtimeTypes:

    • The lint unit file stayed green: 64 passed.
    • The route pins went red, Tests 5 failed | 2 passed: the 4 page slots and the virtual page control. The view refusal and the save control stayed green.
    • Restored: the blob equals HEAD.

Acceptance notes

  • Not walked, by design: a kind: 'react' page's JSX props (validate-react-page-props' territory, unchanged), dashboard widget sortBy and report order[].by (dataset names, not object fields; as before).
  • Existence on page sorts stays advisory. An unknown name in a related list's sort reaches the same door and answers 400, yet page-field-unknown reports it at warning. That is a pre-existing severity call in validate-page-field-bindings, and this PR leaves it unchanged. Reported to the seat; not filed here.
  • Object door not crossed. An object write carrying both sortable: false on a field and a built-in listViews.*.sort on it is judged by os validate, and by the view door for view items, but not by the object publish door. validateSortableFields does not declare object, and reference-integrity-suite.ts records that crossing as its own adjudication ([P0] Studio package publish accepts a dangling highlightFields reference — the authoring gate never sees what the app builder produces #15254's note). Reported to the seat.
  • Stale liveness row. liveness/page.json record still says planned, while ui/…/PageView.tsx reads the binding (objectui#12931). Not changed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzgUAaThh7rNSXWuzXR

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 23 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

38 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d79eecfe5b78598f9117726b542615ca2332d58f.

⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (literal, 56 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 170 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 d79eecfe5b78598f9117726b542615ca2332d58f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 497170c2e0c081b9595f6ace8a9542edd7b04512 — the merge of head c745679c608b987d8810d2b3a2c4b27e03991d62 into base d79eecfe5b78598f9117726b542615ca2332d58f, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d79eecfe5b78598f9117726b542615ca2332d58f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c745679c608b987d8810d2b3a2c4b27e03991d62
Local-runs: none
Scope: card #30023 body and all three comments (triage 6625679577, Claim 6629587477, dev report 6630951308); the seat order /mnt/project-files/server-orders/30023.md; the dev report and replacement body under /mnt/project-files/issue-30023/ against the live PR body (identical); git diff origin/main...pr-30084 in full (two linear commits, 10 files, +710/−44); PR #30021's delta review 6625831334 and its merged changeset .changeset/29896-field-sortable-false-honoured.md (140d8ce); precedent #9257 and its PM review; scripts/check-adr-0087-registration.mjs (already-registered branch), scripts/pm/clause2-line.mjs (arm reader), AGENTS.md Post-Task Checklist §3; resolveObjectSortability / fieldEntriesOf (sortability.zod.ts), walkPageComponents (page-walk.ts), ObjectSearchTarget (validate-searchable-fields.ts), validateReferenceIntegrity dispatch and DEFAULT_MEMBER_RUNTIME_TYPES, runtime-gate.ts:1178, the per-write snapshot in protocol-authoring-gate.ts; the spec sort: keys in component.zod.ts / page.zod.ts; the UI consumers of each walked slot (element-data-source.ts, recordBinding.tsx, record-related-list.tsx, recordPagePayload.ts, LineItemsPanel.tsx, ObjectGrid/Calendar/Gantt/Map/Timeline.tsx, plugin-list/index.tsx, InterfaceListPage.tsx:503, PageView.tsx); validate-page-field-bindings.ts; liveness/page.json; the 23 Supersedes precedents in changeset history; the head's 64 check-runs.

Face: a narrowing of the publish gate. validate-sortable-fields now judges a stored sort by resolveObjectSortability's projection for both reasons, so a sort naming a field declared sortable: false is sort-field-unsortable at error on the five list-view rungs it already walked; it newly walks every stored page sort (block sort, element dataSource.sort, parentDataSource.sort, interfaceConfig.sort, record.sort) for the projection's verdict only, which also refuses a formula sort on those page slots for the first time; validateReferenceIntegrity dispatches on a page write for this one member. protocol.ts / engine.ts untouched; no facade row owed.

① Derived judgments

  • Same projection as the door, both reasons, at the A view's declared sort naming a formula field clears lint, then 400s on every load — the SORT axis has no authoring gate, unlike SEARCH (#6674) #9257 level. unsortableFinding reads projection[name].sortable === false and branches on reason === FIELD_UNSORTABLE_AUTHOR_DECLARED; the projection is resolveObjectSortability(obj).fields over the authored object (indexObjectSortability), the resolver assertSortFieldsExist refuses by. The virtual message is byte-identical to before; the author-declared message carries the door's clause (a field on object "X" that declares \sortable: false`). Both error, the level #9257 landed (validate-sortable-fieldshas always beenerror; triage and the PM review on #9257 ruled it). A formulafield also markedfalsekeepsvirtual-type` because the projection does (pinned on the view and on a page).
  • The second narrowing (virtual sorts on page slots) is real and declared. Before, no page sort was judged for sortability at all, so a page formula sort published green. The Clause-② line says "where a formula sort is now refused too", the Before bullet names it, and a lint pin plus a route control cover it. Same kind (narrowed), one arm; nothing extra owed.
  • Every walked surface reaches the data door. dataSource.sort → element-data-source.ts:503 composes config.sort ?? view.sort into the read; parentDataSource.sort → ParentRecordBinding (recordBinding.tsx:515 $orderby); record:related_list sort → recordPagePayload.ts:169 → related[].sort → findData; record:line_items sort → LineItemsPanel.tsx:400 convertSortToQueryParams(schema.sort); object-grid/calendar/gantt/map/timeline sort → each renderer's $orderby: convertSortToQueryParams(schema.sort); page list-view → ListViewRenderer (a list view's own sort); interfaceConfig.sort → InterfaceListPage.tsx:503; app page record.sort → PageView.tsx:236 $orderby. No false-refusal surface. The PAGE_BLOCK_SORT_OBJECT_PROP table equals the live sort: keys of ComponentPropsMap (seven sort: declarations plus ListViewPageProps' spread; element:record_picker / element:repeater are tombstones), and that equality is pinned with a non-vacuity control.
  • No missed surface. Dashboard sortBy and report order[].by are dataset names (as A view's declared sort naming a formula field clears lint, then 400s on every load — the SORT axis has no authoring gate, unlike SEARCH (#6674) #9257 settled); kind: 'react' pages are validate-react-page-props' (and walkPageComponents already skips them). Nothing else under packages/spec/src/ui carries an object-field sort.
  • Binding precedence matches the renderers. elementDataSourceBlock maps dataSource.object onto the block's object key, so a block sort is judged against dataSource.object ?? <block prop>; interfaceConfig.source ?? page.object matches InterfaceListPage. An object from another package has no projection and is skipped (no refusal on an unresolvable binding).
  • The page door crossing is real and pinned through the real route. validateReferenceIntegrity filters members by options.runtimeWriteType (runtime-gate.ts:1178 sets it to the written type); members without runtimeTypes default to ['flow'], and validateSortableFields is the only member naming page, so a page write runs exactly one member. The per-write snapshot gathers objects on every write (protocol-authoring-gate.ts listCollection('object', 'objects')), so the member has its universe and no missing-collection channel. Pinned at saveMetaItem({ type: 'page' }) on the stub-engine harness: 422 INVALID_METADATA at the four slot paths, nothing stored; the view door likewise; the dispatch roster pin lists validateReferenceIntegrity. Ablation B (drop page) reds exactly the 4 page slots plus the page virtual control; ablation A (author-declared guard off) reds the 7 author-declared cases and the 5 route refusals, controls green. Reported, not re-run here.
  • Exported core stays compatible. checkSortDeclaration's new trailing sortability is optional; absent, the projection is derived from ObjectSearchTarget.fields, which carries type/hidden/searchable and no sortable, so an out-of-repo caller gets the virtual-only answer, as pinned. validate-sortable-fields.ts is the only in-repo caller.
  • Entry text matches the code. surface lists exactly the eight blocks in the table plus the four other slots; acceptanceCriteria is phrased as the os validate finding; the "Lint does not yet judge" sentence is replaced truthfully (the page door now dispatches). The committed docs/protocol-upgrade-guide.md carries neither 18.x entry on main, so it lags unreleased entries by design; no regeneration owed.
  • Corpus. No shipped object declares sortable: false; the rule gives 0 findings over the four example apps, and the all-fields-false probe fires on two showcase interfaceConfig.sort slots, so the page walk is live on real metadata. Reported.

② Semver level

@objectstack/lint and @objectstack/spec both minor; Clause-②: yes (narrowing) with one arm; Metadata-protocol: narrowed; BREAKING banner with Before / Now / Not changed / The remedy. Judged the same: a metadata value refused is narrowed, which takes at least minor on each moved package. "The widening half:" is prose after the arm, not a parenthetical, so readClause2Line reads one arm (the identical spelling in #30021's changeset passed). No FROM → TO label is owed: AGENTS.md ties that mapping to removing or renaming an authorable thing, nothing is removed here, and #30021 (140d8ce) landed the same class with Before/Now/Remedy and no literal label. ADR-0087: not-required (already-registered field-sortable-false-sort-refused) is valid. The id resolves at HEAD and existed at the merge base (140d8ce is an ancestor of c23501b), the why clears 40 characters, and the branch continues before the prescription-contradiction check, so "The remedy." is allowed; registered would be refused by R4 as the dev says, and #9257 used this very category (already-registered engine-find-formula-order-by-refused). Supersedes paragraph: the bolded "Supersedes, in the same release." form is the repo's established shape (the #29904, #27008, #26660 notes), the superseded note is still in .changeset/ on main (unreleased; last version-packages commit predates 140d8ce) and does contain "Lint does not yet judge these at authoring." Skills-docs none — REASON is in the closed vocabulary.

③ Boundary flags

  • CI at head: 64 check-runs, 55 success, 9 skipped, 0 failed (Test Core 8/8, Test UI 16/16, Type Check UI, Build & E2E UI, Live E2E UI, all lint and type-check gates, Check Changeset, PR-body guards, Governed Surface Queue Guard, Spec property liveness).
  • Existence on page sorts is uneven and pre-existing: validate-page-field-bindings reads block sort and interfaceConfig.sort (at warning), but nothing reads element dataSource.sort or an app page record.sort for an unknown name; that name reaches the door and answers 400. Out of this card's scope (the order confined it to the sortability verdict); the dev's class-b finding covers the severity half only.
  • The object publish door does not run validateSortableFields (member lacks object; the reference-integrity-suite.ts note reserves that crossing), so an object write carrying sortable: false and a built-in listViews.*.sort on the same field saves and fails at read; os validate catches it. Pre-existing, reported by the dev.
  • liveness/page.json grades every props.record.* row planned while PageView.tsx#appPageRecordBinding reads record.sort; stale ledger, not moved here.
  • Nit, not owed: a block carrying both dataSource.sort and its own sort has the binding override the block's (element-data-source.ts "binding overrides view"), so the inert properties.sort is still judged. The list-view rungs already behave this way; consistent.

④ Narrowing face

Implemented-by: claude/project-thread-vyggze
Reviewed-by: session_01WGufzibcN9oHa8kDbTTnL5

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

席位核对

Verdict: ACCEPT at head c745679c608b987d8810d2b3a2c4b27e03991d62 · 2026-11-04T13:11Z

  • Shape:

    • validate-sortable-fields judges a stored sort by resolveObjectSortability's projection for both reasons. A sort naming a field declared sortable: false is sort-field-unsortable at error on the five list-view rungs.
    • The rule newly walks every stored page sort (block sort, element dataSource.sort, parentDataSource.sort, interfaceConfig.sort, record.sort) for the projection's verdict only, which also refuses a formula sort on those slots.
    • validateReferenceIntegrity dispatches on a page write for this one member, pinned through the real saveMetaItem route.
    • The 18.field-sortable-false-sort-refused entry's surface text is widened to the full stored list.
  • Review: the CONTRACT_REVIEW_TIER review at this head is PASS (record above).

  • Premise measured: before/after on the lint and route pins. Ablations: A, the author-declared guard turned off (7 cases + 5 route refusals red); B, page dropped from runtimeTypes (4 page slots + the virtual control red). Each was red, then restored. The corpus gives 0 findings over the four example apps, and an all-false probe fires on two showcase interfaceConfig.sort slots.

  • Declaration: Clause-②: yes (narrowing), Metadata-protocol: narrowed, minor on @objectstack/lint and @objectstack/spec, BREAKING with Before / Now / Not changed / The remedy, adr-0087: not-required (already-registered field-sortable-false-sort-refused), and a Supersedes paragraph for 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 pending note. Reviewed as correct. No FROM → TO label is owed, because nothing authorable is removed or renamed (the same class as fix(spec,metadata-protocol)!: honour field.sortable false in the sortability projection and the data door #30021).

  • CI (head): 64 check-runs, 55 success, 9 skipped, 0 failed.

  • Accepted deviations: the harness-pinned branch claude/project-thread-vyggze. The seat files three follow-ups:

    • existence readers and severity for page sorts (element dataSource.sort, app page record.sort);
    • the liveness/page.json props.record.* regrade;
    • the object publish door not dispatching validateSortableFields.

    A nit is carried: the Supersedes paragraph could name the superseded file.

  • Landing: on all-green at this head, the seat sends pr_ready + automerge_enable through the relay. It closes lint: a view sort naming a field the author declared sortable: false clears validate, then 400s on every load once #29896 lands — validate-sortable-fields judges only the virtual predicate #30023.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 4, 2026 13:12
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 4, 2026 13:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit 3a27208 Nov 4, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-vyggze branch November 4, 2026 13:37
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 size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant