Skip to content

fix(spec,metadata-protocol)!: honour field.sortable false in the sortability projection and the data door - #30021

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

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-kqbqmk

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #29896

Clause-②: yes (narrowing) — GET /api/v1/data/:object (and every request that reaches the same list door: POST /api/v1/data/:object/query, the export route, the RPC dispatcher, the record-page related[].sort) and the stdio MCP query_records refuse a sort on a field whose author declared sortable: false, which they used to answer sorted by the column; a stored list view sort or record:related_list sort naming such a field is now refused when it is read. The widening half: @objectstack/spec/api exports FIELD_UNSORTABLE_AUTHOR_DECLARED, and FieldSortabilitySchema.reason accepts 'author-declared' beside 'virtual-type'.
Metadata-protocol: narrowed
Skills-docs: none — skills/** names no sortable field key, and content/docs/** already teaches sortable: false as "do not allow sorting by this field" and INVALID_SORT as "the field must exist and have sortable: true", which is what the platform now does. The prescription ships in the refusal text and the upgrade-guide entry.

维护者速读

  • 改了什么:字段写了 sortable: false 以后,对象元数据里随附的「可排序投影」把它报成不可排序(reason: 'author-declared'),列表表头和排序选择器因此不再提供它;数据接口和 stdio MCP 的 query_records 对这个字段的排序直接 400 INVALID_SORT,和对公式字段排序的拒收是同一个答复。
  • 为什么改:以前作者标了不可排序却毫无效果:投影仍报可排序,GET /api/v1/data/对象?sort=该字段 照样 200 并按它排序,Studio 还把它当成一个有效开关展示。
  • 风险与回滚:这是一次接受集收窄(BREAKING,元数据协议 narrowed,三个包 minor):调用方对 sortable: false 字段的排序从 200 变成 400;已存的列表视图 sort 或 record:related_list 的 sort 若指向这种字段,读取时也会 400,升级指南条目 field-sortable-false-sort-refused 说明怎么改。仓内没有任何对象写 sortable: false;没写这个键或写 true 的字段完全不变;引擎内部调用(钩子、流程)不受影响。回滚就是 revert 本 PR。
  • 先看:packages/metadata-protocol/src/protocol-query-validation.ts 的 assertSortFieldsAreOffered(新判定,按投影拒收)、packages/spec/src/api/sortability.zod.ts 的 resolveObjectSortability(投影读取该键)、packages/rest/src/data-sort-field-sortable-flag-door.test.ts(真实路由钉子)。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/data-sort-field-sortable-flag-door.test.ts,预期 6 个用例全过(投影报不可排序、sort=nosort 400 且未读库、普通字段与 sortable: true 字段双向排序照常、公式字段拒收不变)。
  • 需要你做的:无,席位落地。

What changes

  • The projection honours the author (packages/spec/src/api/sortability.zod.ts). resolveObjectSortability answers { sortable: false, reason: 'author-declared' } for a field whose sortable is the literal false, the same entry shape a virtual type gets ({ sortable: false, reason: 'virtual-type' }). A field with no sortable key, or sortable: true (the schema default once parsed), keeps its storage verdict. A formula field also marked sortable: false keeps reason: 'virtual-type'. FieldSortabilitySchema.reason becomes z.enum(['virtual-type', 'author-declared']), and FIELD_UNSORTABLE_AUTHOR_DECLARED is exported beside FIELD_UNSORTABLE_VIRTUAL_TYPE. The module docblock gains category 6.
  • The data door follows the projection (packages/metadata-protocol/src/protocol-query-validation.ts). assertSortFieldsExist gains a fourth verdict after unknown > dotted > unmaterializable: assertSortFieldsAreOffered computes the same resolveObjectSortability projection over the registered object and refuses any undotted sort name whose entry says sortable: false, through the same invalidSortError (400 INVALID_SORT, param, field, fields, object) the formula refusal uses. It does not read the key a second time, so the door and the affordance cannot disagree. Every caller of findData passes this door: the REST list route, POST /data/:object/query, the export route and the RPC dispatcher.
  • The stdio MCP query_records refuses it too (packages/mcp/src/stdio-data-bridge.ts). That bridge hands orderBy straight to engine.find, so it never passed the list door. query() now calls unofferedSortRefusal after the exposure and stored-metadata refusals and before the engine: the same resolveObjectSortability projection over the object describe_object serves, and the same envelope (INVALID_SORT, status/httpStatus 400, field, fields, object, param: 'orderBy'). The function is exported within the package only, like its siblings; index.ts does not publish it.
  • The engine door is unchanged, on purpose. assertOrderByIsMaterializable (objectql/engine.ts) answers whether a driver can order by a column, and a stored column can. In-process callers (hooks, flows) keep their sort. sortable: false governs what the platform offers callers, not what its storage can do. This is written down in the new function's docblock and in the projection's category 6.
  • Upgrade-guide entry packages/spec/src/migrations/entries/semantic/18.field-sortable-false-sort-refused.ts: the stored surfaces are a list view's sort, a page record:related_list block's sort (it reaches the record-page related[].sort: ui/packages/app-shell/src/views/recordPagePayload.ts#recordPageRelatedLists copies it there) and the related list's dataSource.sort; the replacement is dropping the sort or removing sortable: false. No mechanical rewrite exists, so it is a D3 semantic entry, on the precedent of 17.engine-find-formula-order-by-refused.
  • Liveness: liveness/field.json sortable stays live (the grade does not move) and gains evidence (sortability.zod.ts#resolveObjectSortability, protocol-query-validation.ts#assertSortFieldsAreOffered) and a note naming the pins. Before, the row said "grid." with no evidence while nothing read the key.
  • Regenerated: api-surface/api.json and export-origins/api.json (+1 export), content/docs/references/api/{sortability,protocol}.mdx (the docblock and the reason enum).
  • Changeset: @objectstack/spec, @objectstack/metadata-protocol and @objectstack/mcp, all minor (Metadata-protocol narrowed), BREAKING banner, Before/Now/Stored sorts, ADR-0087 registered field-sortable-false-sort-refused.

Why

The card's reproduction at 589049b0: an object with nosort: { type: 'text', sortable: false } served sortability.fields.nosort = { sortable: true }, and GET /api/v1/data/OBJ?sort=nosort answered 200 sorted. Re-measured on this branch's base eee41490 through the real routes (the new pin file, run before the fix): the projection pins were red with expected { sortable: true } to deeply equal { sortable: false, …(1) }, and sort=nosort answered 200 with the rows in xray, yankee, zulu order (sorted by the column). The formula control in the same file was already 400 INVALID_SORT. So the door's existing answer for a field the projection calls unsortable is a refusal, and this PR gives that answer, which narrows the door's accept set.

Tests

  • packages/rest/src/data-sort-field-sortable-flag-door.test.ts (new, 6), on the data-search-field-searchable-flag-door.test.ts harness: real SqlDriver (SQLite), real ObjectQL, real ObjectStackProtocolImplementation, real RestServer routes.
    • The projection over the registered object: nosort { sortable: false, reason: 'author-declared' }, name and rank { sortable: true }, calc virtual-type.
    • The served GET /api/v1/meta/:type/:name envelope carries the same verdict and deep-equals resolveObjectSortability(item). The envelope's getMetaItem answer is the producer's shape over the registered object, because this harness boots no metadata service.
    • The card's request: sort=nosort, sort=-nosort, $orderby=nosort desc and sort=name,nosort are each 400 INVALID_SORT, naming 'nosort', with zero driver reads.
    • Controls: name (no sortable key) and rank (sortable: true) sort both ways; sort=calc (formula) is still 400 INVALID_SORT "a formula field", with zero driver reads.
  • packages/mcp/src/stdio-data-bridge.sortable-false.test.ts (new, 3), on the stand-in engine of stdio-data-bridge.internal-field-evaluation.test.ts: orderBy on nosort, alone and behind a sortable field, is INVALID_SORT 400 with field, object and param: 'orderBy', and the engine is never called; controls (no sortable key, sortable: true, a filter on the field) reach the engine unchanged; query_records renders the refusal as its envelope.
  • packages/spec/src/api/sortability.test.ts (+6): the author-declared verdict with a no-key neighbour, the PARSED field (FieldSchema.parse: unset and true stay sortable, false does not), only the literal false counts (0, null stay sortable), a formula with sortable: false keeps virtual-type, the array field-map shape, and wire validity under ObjectSortabilitySchema.

Acceptance notes

  • The UI half (read, not run live; corrected by contract review 6624674350). Under manual sorting, ObjectGrid.tsx overrides each generated column's sortable through isPlatformSortableField, and the ListView Sort picker reads the projection. Two gaps remain, and the seat files a domain:ui card for them: ListView.tsx seeds currentSort from the view's stored sort and sends it to the server unfiltered (~L3186; filterPlatformSortableSort covers only the header and the emit/persist path), and under client-side sorting the header is still offered.
  • Authoring gap (reported to the seat, not changed here). packages/lint/src/validate-sortable-fields.ts judges a list view's declared sort for unknown, dotted and virtual names, and not for a field declaring sortable: false. A view whose declared sort names such a field publishes clean, and its sort is then refused at the door, or dropped by the grid's projection filter. No in-repo object authors sortable: false (git grep -nE "sortable\s*:\s*false" outside ui/ and tests: zero producers), so nothing in the shipped corpus moves.
  • Declarations (revised after contract review 6624674350). The door now refuses a request it answered 200, so this is (narrowing); the new export and the widened reason enum make it yes. The first round declared Metadata-protocol: none and not-required (no-migration-prescription). That was wrong: a stored list view sort (and a record:related_list sort) naming a sortable: false field loaded sorted before and is refused when read now, so the kind is narrowed, every moved package goes minor, and the disposition is registered with the semantic entry above. The first changeset's sentence "A sort the door now refuses is a request, never a stored value" is struck.
  • protocol.ts untouched. The door's body lives in protocol-query-validation.ts; the method docblock in protocol.ts is left alone so this PR does not overlap the large-file split of that file.

Verification

Round 2 (head 8738359b), the contract-review rework

Every reading below is at head 8738359b, base eee41490. Main has moved 2 commits since, with no overlapping path, and the local merge-tree is clean. Round 2 touches only the changeset, the upgrade entry, the stdio bridge with its pin, one .describe() sentence and its regenerated page, and the liveness note (git diff --stat bc7630ee..HEAD: 7 files, +225 / −18). protocol-query-validation.ts and the rest pin are unchanged from round 1.

  • The stdio pin, before and after. The pin is packages/mcp/src/stdio-data-bridge.sortable-false.test.ts. Without the guard it reads Tests 2 failed | 1 passed (3): the bridge resolved with the engine called, and query_records answered a result instead of isError. With the guard it reads Tests 3 passed (3).
  • Ablation, once. I made the throw unreachable (if (sortRefusal && 'ABLATED_29896_MCP'.length < 0) throw sortRefusal). The two refusal tests went red and the control stayed green. The file was then restored, the blob matches HEAD, and the pin is green again.
  • pnpm --filter @objectstack/mcp test: Test Files 56 passed, Tests 663 passed. typecheck (tsc + test-typecheck) exits 0 after 8738359b typed the stand-in find. The first run reported one TS2493 in the new file, and no debt ledger entry was added.
  • pnpm --filter @objectstack/spec test: Test Files 811 passed, Tests 24376 passed. This includes the semantic-migration registry suites, with the new entry field-sortable-false-sort-refused.
  • The rest pin data-sort-field-sortable-flag-door.test.ts: 6 passed (unchanged). eslint on the changed files: exit 0. check:generated --fix regenerated only content/docs/references/api/sortability.mdx, which is committed in f320e8de.
  • check-changeset-no-major --base origin/main, with this body as the event: exit 0. It reads metadata-protocol narrowed, and no package whose published source it moves is graded patch. check-adr-0087-registration --base origin/main: exit 0, registered field-sortable-false-sort-refused (new here).
  • pnpm --filter @objectstack/dogfood test at 8738359b, under the verify lock, after turbo build --filter=@objectstack/mcp... --filter=@objectstack/dogfood^... (69/69): Test Files 416 passed | 3 skipped (419), Tests 3456 passed | 28 skipped (3484), with no failures.
  • node scripts/pm/dispatch-gates.mjs --commands derives 107 commands at 8738359b: round 1's 105, plus check:spec-changes and check:upgrade-guide. All 107 exit 0. The --ran reconciliation reads "107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED".

Round 1 (head bc7630ee)

Every reading below is at head bc7630ee, base eee41490 (main has moved one unrelated commit since, 9d1a465f0; no overlapping path, local merge-tree clean).

The card's reproduction, before and after (the new pin file)
  • Before the fix (base eee41490, same harness): Tests 3 failed | 3 passed (6). The projection pins failed with expected { sortable: true } to deeply equal { sortable: false, …(1) }, and {"sort":"nosort"} answered 200 with records in xray, yankee, zulu order (sorted by nosort): expected 200 to be 400. The three controls passed, including sort=calc (formula) already 400 INVALID_SORT.
  • After: Tests 6 passed (6).
Ablation (one, of the projection change)

Mutation: | undefined)?.sortable === false → | undefined)?.sortable === 'ABLATED_29896' in sortability.zod.ts, via scripts/ablation-replace.mjs (anchor 1 → 0, blob a15d6b6a4b9a → 2e0f03fd3415). Then pnpm --filter @objectstack/spec build, and ablation-dist-preflight.mjs @objectstack/spec ABLATED_29896 exited 0 (marker in dist/api/index.js and index.mjs).

  • Red: the rest pin file gave 3 failed / 3 passed. The projection pin, the served-envelope pin and the card's sort=nosort refusal went red, which shows the door judges by the projection. The three controls stayed green. The spec sortability.test.ts gave 3 failed / 15 passed (the author-declared pins).
  • Restore: blob back to a15d6b6a4b9a (== HEAD), git diff HEAD empty, git status --porcelain clean, spec rebuilt, --absent preflight exit 0 over 592 built files.
Package tests and typecheck
  • pnpm --filter @objectstack/spec test: Test Files 811 passed, Tests 24376 passed | 1 todo; typecheck exit 0.
  • pnpm --filter @objectstack/metadata-protocol test: Test Files 312 passed | 4 skipped, Tests 22346 passed | 26 skipped; typecheck exit 0.
  • pnpm --filter @objectstack/rest test: Test Files 385 passed, Tests 7184 passed | 332 skipped; typecheck (tsc + test-typecheck) exit 0 after bc7630ee typed the request list (the first run reported one TS2345 in the new file; no debt ledger entry was added).
  • eslint --no-inline-config over the four changed source/test files: exit 0.
  • pnpm lint (repo-wide): exit 0 at bc7630ee.
Full build and dogfood (narrowing arm)
  • turbo run build --concurrency=2 --filter=!@objectstack/docs: 119/119 tasks successful.
  • pnpm --filter @objectstack/dogfood test at bc7630ee, under the verify lock: VERDICT command-exit 0, Test Files 416 passed | 3 skipped (419), Tests 3456 passed | 28 skipped (3484), no failures.
Derived gates (dispatch-gates --commands)

node scripts/pm/dispatch-gates.mjs --commands derives 105 commands (the same list at 61e984ef and at bc7630ee). All 105 ran after the full build. --ran reconciliation: "105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED".

  • One command exited 3 for an environment reason and reran green: check-plugin-teardown-shape --self-test could not read its pinned fixture commit on the shallow clone; after git fetch origin 621a4876… it passed (48 cases).
  • check-changeset-no-major with this body as the event: exit 0, "declares clause-② yes (narrowing) and metadata-protocol none … introduces no changeset entry graded minor or above and moves no metadata-protocol baseline".
  • check-adr-0087-registration with this body as the event: exit 0, [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription).
  • pnpm --filter @objectstack/spec check:generated --fix regenerated exactly the three it proved stale (api-surface/, export-origins/, content/docs/references/**). check:authorable-surface and check:liveness are green.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

@github-actions github-actions Bot added the size/m label Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/mcp, @objectstack/metadata-protocol, @objectstack/spec, touching 17 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/export-origins/api.json, packages/spec/liveness/field.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

42 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 63b86b385dd2cbfeba131438112c49be2c74df91.

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

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/export-origins/api.json, packages/spec/liveness/field.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: /data/:object (route, 76 pages)
  • 3 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 — 175 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 63b86b385dd2cbfeba131438112c49be2c74df91 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9ed311cb85d83c624ec815e936a829b9426fbc65 — the merge of head 8738359bde443b9bca880039ffb1b5d017d61813 into base 63b86b385dd2cbfeba131438112c49be2c74df91, 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 9ed311cb85d83c624ec815e936a829b9426fbc65 && git checkout 9ed311cb85d83c624ec815e936a829b9426fbc65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 63b86b385dd2cbfeba131438112c49be2c74df91 8738359bde443b9bca880039ffb1b5d017d61813 && git checkout -B drift-repro 63b86b385dd2cbfeba131438112c49be2c74df91 && git merge --no-ff 8738359bde443b9bca880039ffb1b5d017d61813

node scripts/docs-audit/affected-docs.mjs --json 63b86b385dd2cbfeba131438112c49be2c74df91

⚠️ 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 63b86b385dd2cbfeba131438112c49be2c74df91 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added 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
@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: bc7630ee57def483853be294b43e2771238ec002
Local-runs: none
Scope: card #29896 and its comments (6601906029, 6623292940, 6624449982); the seat order and /mnt/project-files/issue-29896/os-dev-report.json; the PR body, files, full diff and the head's check-runs. At pr-30021: packages/spec/src/api/sortability.zod.ts and its test; packages/metadata-protocol/src/protocol-query-validation.ts (resolveQueryFields, assertSortFieldsExist, assertSortFieldsAreOffered, UNMATERIALIZED_SORT_TYPES); protocol.ts (findData and the sort docblock); packages/rest/src/data-sort-field-sortable-flag-door.test.ts, crud-routes.ts, data-action-routes.ts (export), record-page-routes.ts; packages/runtime/src/domains/data.ts, mcp.ts; packages/mcp/src/stdio-data-bridge.ts, mcp-http-tools.ts, plugin.ts; packages/objectql/src/engine.ts (assertOrderByIsMaterializable), registry.ts (mergeObjectDefinitions); packages/spec/src/data/field-customization.ts, authorable-surface.base.json, liveness/field.json, api-surface/api.json, export-origins/api.json; the semantic entries 17.engine-find-formula-order-by-refused.ts and 17.sort-node-direction-rejected.ts; packages/lint/src/validate-sortable-fields.ts; the changeset. Cited ui code read, not reviewed: plugin-grid objectGridGenerateColumns.tsx, ObjectGrid.tsx; plugin-list ListView.tsx; core column-sortability.ts. On main: AGENTS.md step 3, contract-review.md, SKILL.md:313, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, ADR-0087.

Face: accept-set narrowing at the Data API list door (GET /api/v1/data/:object?sort=, POST /data/:object/query, export, RPC). A sort that names a field whose author declared sortable: false used to answer 200 sorted and now answers 400 INVALID_SORT. There is also a published-schema move: the FieldSortabilitySchema.reason enum widens and FIELD_UNSORTABLE_AUTHOR_DECLARED is a new export. Run in-seat as an isolated reviewer at CONTRACT_REVIEW_TIER, fed the card, the PR and the head's check-runs only.

① Derived judgments

  • The projection change is correct. resolveObjectSortability reads only a literal false, and only after the virtual check. No key, true, 0 or null all project sortable: true, while a formula with sortable: false projects virtual-type, so the door and the projection agree. Both array and map field shapes are read. The unprovisioned-anchor loop cannot overwrite an author's false. System columns are absent from the projection, so they stay accepted. An extender cannot patch sortable onto an owner's field: mergeObjectDefinitions replaces a field whole, and the whitelist excludes sortable. The door and the projection read the same merged registry object.

  • The door refusal is correct on every findData path, but one public door is still open. assertSortFieldsAreOffered is the fourth verdict, after unknown, dotted and unmaterializable, and it uses the same invalidSortError. Every findData caller reaches it: REST list, query, export, record-page related[].sort, RPC, and the runtime MCP domain. The stdio MCP query_records does not. stdio-data-bridge.ts query() forwards orderBy straight into engine.find (L993–999, wired by plugin.ts:727), and the engine door judges formula only. That call still answers sorted by a sortable: false field. "Every caller of findData passes this door" is true but not sufficient. Category 6 now says the key governs "the Data API", and the changeset's Not changed names only hooks and flows.

  • A stored view's sort is now refused at read time, and the changeset misstates it. ListViewSchema.sort is authored metadata. ListView.tsx seeds currentSort from schema.sort and sends it to the server unfiltered (L3186). filterPlatformSortableSort applies only to the header and to the emit/persist path. Lint validate-sortable-fields judges only the virtual predicate. So a published view whose sort names a sortable: false field used to load sorted, and after this PR every fetch of it answers 400, with no authoring gate between. The sentence "A sort the door now refuses is a request, never a stored value" is false.

  • The Clause-② line is right. yes (narrowing) is well-formed and the arm is correct.

  • Metadata-protocol: none is wrong, on two grounds.

    • (a) authorable-surface.base.json lists api/FieldSortability:reason. A new accepted enum member is therefore at least added. A green check:authorable-surface does not settle this, because that gate tracks keys, not enum members.
    • (b) An authored view sort is now refused, which makes it narrowed, as the seat order anticipated.

    The kind is narrowed, so at least minor is owed on the moved packages, and both patch entries are wrong.

  • The ADR-0087 disposition not-required (no-migration-prescription) is wrong. A stored surface moves (a view's sort), and the body prescribes a remedy ("drops the sort … removes sortable: false"). The precedent is exact: 17.engine-find-formula-order-by-refused registered an entry for an ORDER BY refusal with one author-reachable stored surface.

  • Liveness is right. sortable stays live and gains evidence and a note.

  • The ui half, read only, is half right. Under manualSortingOn, withSortability overrides the explicit column's sortable, so the header is correct there. The ListView Sort picker reads the projection. Client-side sorting still offers the header, and the fetch does not filter the stored sort, so a domain:ui card is owed.

  • The pins and the ablation are adequate for the order's scope. The real GET route runs with zero driver reads, the four sort shapes are pinned, and the controls cover no key, true and formula. One ablation turns the door pin red. Not pinned: the stdio MCP door, the engine path (the Not changed claim is asserted, not measured), and a stored view sort through the Console.

② Semver level

Declared: patch on @objectstack/spec and on @objectstack/metadata-protocol, Clause-②: yes (narrowing), Metadata-protocol: none, the BREAKING banner, not-required (no-migration-prescription). Judged: Clause-② is right, and Metadata-protocol is narrowed. Each moved package therefore takes minor, and the ADR-0087 disposition becomes registered <id> with a semantic entry. check-changeset-no-major passed only because none + patch agree with each other; the kind itself is wrong. The changeset is not consistent with the diff.

③ Boundary flags

  • Stdio MCP query_records still sorts by a sortable: false field (REWORK-3).
  • Authoring gap: lint validate-sortable-fields does not judge a view sort that names a sortable: false field. The seat files this card, and the semantic entry names it as the remedy's gate.
  • A domain:ui card is owed: ListView's fetch-side sort filter (L3186), and the header under client-side sorting. The seat files it.
  • Record page related[].sort is now refused per part through findData. If the Console builds it from authored related-list metadata, that is a second stored surface. The dev verifies this and names it in the entry.
  • Check-runs at review: lint, typecheck, liveness and the PR-body guards green; the Core, UI and E2E suites still in progress.

④ Narrowing face

  • sortability.zod.ts: the resolver is correct. The category-6 docblock's claim that the key governs "the Data API" is false for stdio query_records.
  • protocol-query-validation.ts: the placement, the error constructor and the "ingress only" docblock are all correct. What is wrong is the declaration around the code, not the code itself.
  • Changeset changes owed:
    • Metadata-protocol: none → narrowed.
    • Every moved package → minor.
    • The ADR-0087 disposition → registered <id>.
    • Strike "never a stored value".
    • Not changed names the stdio door, unless the PR closes it.

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

VERDICT: REWORK

REWORK items:

  1. (blocking) Set Metadata-protocol: narrowed in both the PR body and the changeset, and raise @objectstack/spec and @objectstack/metadata-protocol to minor. Then re-run check-changeset-no-major and check-adr-0087-registration against the new body.

  2. (blocking) Change the ADR-0087 disposition to registered <id>. Add a semantic entry under packages/spec/src/migrations/entries/semantic/ on the precedent 17.engine-find-formula-order-by-refused.ts:

    • Surface: a list view sort that names a sortable: false field, plus the record-page related[].sort if it is authored.
    • Replacement: drop the sort, or remove sortable: false.
    • Acceptance: no view sort names such a field.

    Regenerate the registry, and strike "A sort the door now refuses is a request, never a stored value".

  3. (blocking) The stdio MCP door (stdio-data-bridge.ts query → engine.find) needs one of two fixes:

    • Refuse there with the same INVALID_SORT and pin it. This is preferred, because it is the same projection in the same package's bridge.
    • Or state in Not changed and in the category-6 docblock that stdio query_records still sorts by the field, and report it back so the seat files the card.
  4. (seat) File the lint card for validate-sortable-fields and the domain:ui card for ListView's fetch filter and the client-side-sorting header.

…narrowed declaration and upgrade entry (#29896)

Claude-Session: https://claude.ai/code/session_01WGufzhnsFtaPy7QwK43MYt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Nov 4, 2026
@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: 8738359bde443b9bca880039ffb1b5d017d61813
Local-runs: none
Scope: the REWORK record 6624674350 in full; git diff bc7630ee..8738359b (three linear commits, 7 files, +225/−18); the head's check-runs and the failing Check Changeset job log; the replacement body /mnt/project-files/issue-29896/pr-body-r1.md against the live body.

At 8738359b:

  • the changeset;
  • 18.field-sortable-false-sort-refused.ts, against SemanticMigration, the precedent 17.engine-find-formula-order-by-refused.ts, semantic-relevance.test.ts, the registry and upgrade-guide builders, and .gitignore;
  • stdio-data-bridge.ts (unofferedSortRefusal, query(), stdioObjectView, enforceApiExposure), its new test, and mcp-http-tools.ts (the sort → orderBy alias, tool-error rendering);
  • protocol-query-validation.ts, invalidSortError, assertOrderByIsMaterializable, sortability.zod.ts, liveness/field.json.

The stored surfaces:

  • RecordRelatedListProps.sort, ElementDataSourceSchema.sort and PageComponentSchema.dataSource;
  • recordPageRelatedLists, record-page-routes.ts (orderBy: entry.sort → findData), record-related-list.tsx, RelatedList.tsx, ListView.tsx;
  • check-adr-0087-registration.mjs and check-changeset-no-major.mjs: what each reads, and from where.

Face: delta review of the fix-up after REWORK 6624674350. The round-2 change is confined to the declaration, one new semantic entry, the stdio MCP door with its three-test pin, the category-6 docblock with its regenerated page, and the liveness note. protocol-query-validation.ts, protocol.ts, engine.ts, the rest pin and the resolver are byte-identical to round 1.

① Derived judgments

  • REWORK-1 is closed in the changeset.

    • Declaration: @objectstack/spec, @objectstack/metadata-protocol and the newly moved @objectstack/mcp are all minor. Metadata-protocol: narrowed. Clause-②: yes (narrowing) has one arm.
    • The head's ADR-0087 gate reads registered field-sortable-false-sort-refused (new here).
    • The one red, check-changeset-no-major, fails only because it reads Metadata-protocol: none from the live PR body. The replacement body says narrowed, so this red is the seat's apply step, not the diff.
  • REWORK-2 is closed.

    • Entry: it carries every required SemanticMigration field and no conversionIds / relevantWhen, so the relevance roster is untouched, and the spec suite is green.
    • Registry: registry.ts is gitignored and generated at install and build, and the upgrade guide is generated too. check:spec-changes and check:upgrade-guide are green.
    • The id is new in the diff. "Never a stored value" is struck and replaced by a Stored sorts bullet.
  • Every listed stored surface is real and reaches the door.

    • RecordRelatedListProps.sort is copied into related[].sort, and record-page-routes.ts passes it as orderBy into findData. The list's own read also sends it as $orderby.
    • The related list's dataSource.sort maps onto defaultSort and is sent the same way.
    • The list view sort was established in round 1.
  • The surface list is under-inclusive, but not wrong. The entry names only the related list's dataSource.sort, yet more stored sorts reach the same door:

    • every page element's ElementDataSourceSchema.sort: list-view block, kanban, map, gantt, timeline, report;
    • a list page's own sort (page.zod.ts:1057);
    • an app page record binding's sort (:878).

    The acceptance criteria ("grep your views and pages for each sort field") cover them in substance. Filed.

  • REWORK-3 is closed by the preferred fix, and it judges by the same projection.

    • Placement: unofferedSortRefusal runs after the exposure gate and the stored-metadata refusals, and before engine.find.
    • Verdict: it calls the same resolveObjectSortability(def).fields with the data door's predicate. The object comes from the protocol's merged object listing, which is the same merged registry object the data door reads. So the two doors cannot disagree on a verdict.
  • The error shape is the Data API's, with one truthful difference.

    • Same stamps: INVALID_SORT, status: 400, field, fields, object. httpStatus: 400 is added, as the engine's own INVALID_SORT does.
    • The difference: param: 'orderBy', which is the tool's real argument name.
    • Message: the same clause and remedy, plus "the query was not run". It is rendered as a tool error, and that is pinned.
  • Precedence differs in one benign respect. For an orderBy that names both a formula field and a sortable: false field, REST names the formula first and stdio names the sortable: false field first. Both answer 400 INVALID_SORT. The docblock's "fourth verdict" is true of the verdict's identity, not of its position on this transport. An unknown orderBy on stdio still reaches the driver, which is pre-existing and not moved here.

  • The HTTP MCP path is unchanged and goes through findData, closed in round 1.

  • Pins and ablation.

    • Three tests: the refusal alone and behind a sortable field, with engine.find never called; the controls (no key, true, no sort, a filter on the field) reach the engine; and the rendered envelope.
    • Reported, not re-run here: before/after 2 failed | 1 passed → 3 passed, and one ablation that turns the two refusal tests red.
    • sortable stays live, and its note names the new pin.

② Semver level

minor on @objectstack/spec, @objectstack/metadata-protocol and @objectstack/mcp. Clause-②: yes (narrowing), Metadata-protocol: narrowed, BREAKING, <!-- adr-0087: registered field-sortable-false-sort-refused -->. Judged the same.

  • Kind: narrowed, because a stored view sort and a record:related_list sort are refused at read. The new refusal at the public stdio tool is a second arm of the same kind, and it is declared in the Clause-② line, the banner, Before/Now and the entry.
  • Consistency: the changeset agrees with the diff. The live body does not, and the replacement does.

③ Boundary flags

④ Narrowing face

  • stdio-data-bridge.ts: the placement, the shared resolver, the envelope and the message are correct. unofferedSortRefusal is package-internal, and index.ts does not publish it.
  • 18.field-sortable-false-sort-refused.ts: the shape, the precedent and the remedy are right. The surface prose widens on a follow-up.
  • The changeset and pr-body-r1.md are consistent:
    • Clause-② has one arm, and Metadata-protocol: narrowed.
    • Skills-docs: none — REASON is in the closed vocabulary, and the marker resolves.
    • Not changed no longer needs the stdio door.

Implemented-by: claude/project-thread-kqbqmk
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 8738359bde443b9bca880039ffb1b5d017d61813 · 2026-11-04T08:04Z

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