Skip to content

Commit 140d8ce

Browse files
fix(spec,metadata-protocol)!: honour field.sortable false in the sortability projection and the data door (#30021)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB6YVzCyL9h4KmxxZsHSmuk7)_ 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 bc7630e..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). <details><summary>The card's reproduction, before and after (the new pin file)</summary> - 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)`. </details> <details><summary>Ablation (one, of the projection change)</summary> 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. </details> <details><summary>Package tests and typecheck</summary> - `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`. </details> <details><summary>Full build and dogfood (narrowing arm)</summary> - `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. </details> <details><summary>Derived gates (dispatch-gates --commands)</summary> `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 621a487…` 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. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzhnsFtaPy7QwK43MYt)_ <!-- signed: domain:server#6 · os-support-ai · session_01WGufzhnsFtaPy7QwK43MYt --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e8afd1e commit 140d8ce

13 files changed

Lines changed: 577 additions & 25 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/mcp': minor
5+
---
6+
7+
fix(spec,metadata-protocol,mcp)!: a field marked `sortable: false` is no longer offered or performed as a sort — the sortability projection reports it unsortable, and the Data API and the stdio MCP `query_records` refuse a sort on it with `400 INVALID_SORT` (#29896)
8+
9+
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'`.
10+
Metadata-protocol: narrowed
11+
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.
12+
13+
<!-- adr-0087: registered field-sortable-false-sort-refused -->
14+
15+
**BREAKING** accept-set narrowing at the Data API list door (`@objectstack/metadata-protocol`) and the stdio MCP `query_records` (`@objectstack/mcp`): a sort on a field marked `sortable: false` is refused.
16+
17+
- **Before.** An author who marked a field `sortable: false` (a long text, an unindexed column on a large table) got nothing for it. The sortability projection served on `GET /api/v1/meta/object/NAME` reported the field `{ sortable: true }`, so the list header and the Sort picker offered it, `GET /api/v1/data/NAME?sort=FIELD` answered 200 sorted by it, and so did the stdio MCP `query_records` with that `orderBy`.
18+
- **Now.** The projection reports `{ sortable: false, reason: 'author-declared' }` for that field, so the grid header and the Sort picker, which read the projection, stop offering it. The Data API judges the sort by the same projection and refuses it with `400 INVALID_SORT` before any read, the answer a sort on a `formula` field already gets; the stdio MCP `query_records` refuses it the same way before the engine runs. Only the literal `false` counts: a field with no `sortable` key, or with `sortable: true`, sorts as before. A `formula` field also marked `sortable: false` keeps its `reason: 'virtual-type'` answer.
19+
- **Stored sorts.** A list view whose `sort` names such a field, or a page `record:related_list` block whose `sort` does, used to load sorted and is now refused when it is read. Lint does not yet judge these at authoring. The upgrade-guide entry `field-sortable-false-sort-refused` lists them.
20+
- **Not changed.** In-process callers that reach `ObjectQL.find` directly (hooks, flows) keep sorting by the stored column: the engine door judges only whether a column is stored, and `sortable: false` governs what the platform offers callers, not what its storage can do.
21+
22+
**The remedy.** Drop the sort on that field or sort by another one, in requests and in stored view and related-list sorts. An author who wants the field sortable removes `sortable: false` from it.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1471,7 +1471,7 @@ This schema accepts one of the following structures:
14711471

14721472
| Property | Type | Required | Description |
14731473
| :--- | :--- | :--- | :--- |
1474-
| **fields** | `Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }>` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. |
1474+
| **fields** | `Record<string, { sortable: boolean; reason?: Enum<'virtual-type' \| 'author-declared'>; caveat?: 'unprovisioned-anchor' }>` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. |
14751475

14761476
### Nested Shape: `GetMetaItemResponse.registration[state='registered']`
14771477

‎content/docs/references/api/sortability.mdx‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ records as deliberately rejected).
3535
"Unsortable" is judged by what the two runtime doors —
3636
`assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and
3737
`assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually
38-
do with an `orderBy` over the name. Three verdicts are REFUSALS, and one
39-
measured degradation is not refused; the projection covers all four, and
38+
do with an `orderBy` over the name. Four verdicts are REFUSALS, and one
39+
measured degradation is not refused; the projection covers all five, and
4040
one more absence that is a fact about the CALLER, not the column (5.):
4141

4242
1. **Unknown name** — not a field of the object. Refused (`400
@@ -51,10 +51,10 @@ one more absence that is a fact about the CALLER, not the column (5.):
5151
3. **Virtual type** (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`
5252
— exactly `formula` today) — computed on read, no driver materialises a
5353
column. Refused at both doors. Encoded as `sortable: false` with
54-
`reason: 'virtual-type'`. This is the ONE per-field refusal fact, and it
55-
is judged by the same spec predicate the linter
56-
(`validate-sortable-fields`) and the search axis read — never by a local
57-
type list.
54+
`reason: 'virtual-type'`. This is the one per-field STORAGE refusal
55+
fact (6. is the author's), and it is judged by the same spec predicate
56+
the linter (`validate-sortable-fields`) and the search axis read — never
57+
by a local type list.
5858
4. **Unprovisioned injected anchor** (`unprovisionedInjectedColumns`
5959
— the platform's own injected columns on an ADR-0015 `external` object,
6060
which registers them and provisions no storage). NOT refused: both doors
@@ -74,6 +74,23 @@ one more absence that is a fact about the CALLER, not the column (5.):
7474
`setup.access`), who IS served the field in `item.fields` because
7575
authoring needs the whole definition. For every other caller the mask
7676
has already removed the field from `item`, so this is case 1's absence.
77+
6. **Declared unsortable by its author** (`field.sortable: false`, #29896)
78+
— a stored column the author chose not to offer as a sort (a long text,
79+
an unindexed column on a large table). Refused at the INGRESS door
80+
(`assertSortFieldsExist`: `400 INVALID_SORT`) and on the stdio MCP
81+
`query_records` (`unofferedSortRefusal`, the same envelope), both of
82+
which judge it by THIS projection rather than by a second reading of the
83+
key. Encoded as
84+
`sortable: false` with `reason: 'author-declared'`. Only the literal
85+
`false` counts: a field with no `sortable` key (or `sortable: true`)
86+
keeps its storage verdict. A virtual field marked `sortable: false`
87+
keeps `reason: 'virtual-type'` — the storage fact is the stronger one.
88+
The engine door (`assertOrderByIsMaterializable`) does NOT judge it: that
89+
door answers "can a driver order by this column", and a stored column
90+
can be ordered by, so an in-process caller (a hook, a flow) keeps sorting
91+
by it. The key governs what the platform OFFERS — the list header, the
92+
Sort picker, the Data API and the MCP tools — not what the storage can
93+
do.
7794

7895
## Considered and deliberately NOT members
7996

@@ -132,7 +149,7 @@ const result = FieldSortabilitySchema.parse(data);
132149
| Property | Type | Required | Description |
133150
| :--- | :--- | :--- | :--- |
134151
| **sortable** | `boolean` | ✅ | Whether the platform honors an ORDER BY over this field. `false` means the runtime REFUSES the sort (`400 INVALID_SORT`) — render no sort affordance. `true` means the sort is accepted; see `caveat` for the one accepted-but-degradable case. A derived verdict: do not recompute it from field `type` client-side. |
135-
| **reason** | `'virtual-type'` | optional | Present exactly when `sortable` is false: the field's type is virtual (computed on read, no stored column), so no driver materialises anything to ORDER BY. |
152+
| **reason** | `Enum<'virtual-type' \| 'author-declared'>` | optional | Present exactly when `sortable` is false, naming why. `virtual-type`: the field's type is virtual (computed on read, no stored column), so no driver materialises anything to ORDER BY. `author-declared`: the field declares `sortable: false`, so the Data API refuses a sort on it although a column is stored. |
136153
| **caveat** | `'unprovisioned-anchor'` | optional | Present only with `sortable: true`: the field is a platform-injected anchor on an ADR-0015 `external` object with no storage provisioned behind it. The runtime accepts the sort, but when the remote table carries no such column the ORDER BY is silently dropped (asc === desc under a 200). Consumers may choose a conservative affordance for these entries. |
137154

138155

@@ -144,14 +161,14 @@ const result = FieldSortabilitySchema.parse(data);
144161

145162
| Property | Type | Required | Description |
146163
| :--- | :--- | :--- | :--- |
147-
| **fields** | `Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }>` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. |
164+
| **fields** | `Record<string, { sortable: boolean; reason?: Enum<'virtual-type' \| 'author-declared'>; caveat?: 'unprovisioned-anchor' }>` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in `item` and gets no entry for them; for every other caller the served map already excludes them. A name absent from this map (an unknown field, a dotted path, an unprovisioned audit column, a field the caller may not read) has no platform sort behind it and must get no sort affordance. |
148165

149166
### Nested Shape: `ObjectSortability.fields[string]`
150167

151168
| Property | Type | Required | Description |
152169
| :--- | :--- | :--- | :--- |
153170
| **sortable** | `boolean` | ✅ | Whether the platform honors an ORDER BY over this field. `false` means the runtime REFUSES the sort (`400 INVALID_SORT`) — render no sort affordance. `true` means the sort is accepted; see `caveat` for the one accepted-but-degradable case. A derived verdict: do not recompute it from field `type` client-side. |
154-
| **reason** | `'virtual-type'` | optional | Present exactly when `sortable` is false: the field's type is virtual (computed on read, no stored column), so no driver materialises anything to ORDER BY. |
171+
| **reason** | `Enum<'virtual-type' \| 'author-declared'>` | optional | Present exactly when `sortable` is false, naming why. `virtual-type`: the field's type is virtual (computed on read, no stored column), so no driver materialises anything to ORDER BY. `author-declared`: the field declares `sortable: false`, so the Data API refuses a sort on it although a column is stored. |
155172
| **caveat** | `'unprovisioned-anchor'` | optional | Present only with `sortable: true`: the field is a platform-injected anchor on an ADR-0015 `external` object with no storage provisioned behind it. The runtime accepts the sort, but when the remote table carries no such column the ORDER BY is silently dropped (asc === desc under a 200). Consumers may choose a conservative affordance for these entries. |
156173

157174

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29896] The stdio data bridge's `query` refuses a sort on a field whose
5+
* author declared `sortable: false` with the Data API list door's
6+
* `400 INVALID_SORT`, before the engine runs anything. This transport hands
7+
* `orderBy` straight to `engine.find`, which judges only whether a column is
8+
* stored, so the stdio `query_records` was the one public door that still
9+
* sorted by such a field. The verdict is the served sortability projection's
10+
* (`resolveObjectSortability`), over the object `describe_object` serves.
11+
*
12+
* Controls: a field with no `sortable` key, a field with `sortable: true`, and
13+
* a query with no sort reach the engine unchanged. Refusals are asserted on the
14+
* envelope, never on a bare throw; the harness is the stand-in engine of
15+
* `stdio-data-bridge.internal-field-evaluation.test.ts`.
16+
*/
17+
18+
import { describe, it, expect, vi } from 'vitest';
19+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
20+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
21+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
22+
import type { ExecutionContext } from '@objectstack/spec/kernel';
23+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
24+
import { createStdioDataBridge } from './stdio-data-bridge.js';
25+
import { registerObjectTools } from './mcp-http-tools.js';
26+
27+
const OBJECT = 'sfx_note';
28+
const DEF = {
29+
name: OBJECT,
30+
fields: {
31+
name: { type: 'text' },
32+
nosort: { type: 'textarea', sortable: false },
33+
rank: { type: 'number', sortable: true },
34+
},
35+
};
36+
37+
const PRINCIPAL = { userId: 'u1', isSystem: false } as unknown as ExecutionContext;
38+
39+
function makeBridge() {
40+
const engine = {
41+
find: vi.fn(async (_object: string, _query: unknown) => [{ id: 'r1', name: 'alpha' }]),
42+
};
43+
const metadataService = { getObject: vi.fn(async (name: string) => (name === OBJECT ? DEF : undefined)) };
44+
const bridge = createStdioDataBridge({
45+
engine: engine as unknown as IDataEngine,
46+
metadataService: metadataService as unknown as IMetadataService,
47+
resolvePrincipal: async () => PRINCIPAL,
48+
});
49+
return { bridge, engine };
50+
}
51+
52+
const refusalOf = async (run: () => Promise<unknown>) => {
53+
const err: any = await run().then(() => undefined, (e: unknown) => e);
54+
expect(err, 'the call resolved: the sort refusal did not reach the caller').toBeDefined();
55+
return { code: err.code, status: err.status, field: err.field, object: err.object, param: err.param };
56+
};
57+
const REFUSED = { code: 'INVALID_SORT', status: 400, field: 'nosort', object: OBJECT, param: 'orderBy' };
58+
59+
describe('[#29896] stdio query: a sort on a `sortable: false` field is refused before the engine', () => {
60+
it('refuses the sort, alone or behind a sortable one, and never calls the engine', async () => {
61+
const { bridge, engine } = makeBridge();
62+
for (const orderBy of [
63+
[{ field: 'nosort', order: 'asc' as const }],
64+
[{ field: 'name', order: 'asc' as const }, { field: 'nosort', order: 'desc' as const }],
65+
]) {
66+
expect(await refusalOf(() => bridge.query(OBJECT, { orderBy }))).toEqual(REFUSED);
67+
}
68+
expect(engine.find).not.toHaveBeenCalled();
69+
});
70+
71+
it('CONTROL — no `sortable` key, `sortable: true` and no sort at all reach the engine unchanged', async () => {
72+
const { bridge, engine } = makeBridge();
73+
const orderBy = [{ field: 'name', order: 'asc' as const }, { field: 'rank', order: 'desc' as const }];
74+
await bridge.query(OBJECT, { orderBy, limit: 5 });
75+
expect(engine.find).toHaveBeenCalledTimes(1);
76+
expect(engine.find.mock.calls[0]![1]).toMatchObject({ orderBy, limit: 5 });
77+
// A filter on the field is not a sort: `sortable: false` governs ordering only.
78+
await bridge.query(OBJECT, { where: { nosort: 'x' } });
79+
expect(engine.find).toHaveBeenCalledTimes(2);
80+
});
81+
82+
it('query_records renders the refusal as its envelope', async () => {
83+
const { bridge, engine } = makeBridge();
84+
const server = new McpServer({ name: 'pin', version: '0' });
85+
registerObjectTools(server, bridge);
86+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
87+
await server.connect(serverSide);
88+
const client = new Client({ name: 'pin', version: '0' });
89+
await client.connect(clientSide);
90+
try {
91+
const res = (await client.callTool({
92+
name: 'query_records',
93+
arguments: { objectName: OBJECT, orderBy: [{ field: 'nosort', order: 'asc' }] },
94+
})) as { isError?: boolean; content: Array<{ text: string }> };
95+
expect(res.isError).toBe(true);
96+
const { error } = JSON.parse(res.content[0]!.text) as { error: { code: unknown; status: unknown; message: string } };
97+
expect({ code: error.code, status: error.status }).toEqual({ code: 'INVALID_SORT', status: 400 });
98+
expect(error.message).toContain("'nosort'");
99+
expect(engine.find).not.toHaveBeenCalled();
100+
} finally {
101+
await client.close();
102+
}
103+
});
104+
});

0 commit comments

Comments
 (0)