diff --git a/.changeset/meta-object-sortability-projection.md b/.changeset/meta-object-sortability-projection.md
new file mode 100644
index 0000000000..8bab42f5ab
--- /dev/null
+++ b/.changeset/meta-object-sortability-projection.md
@@ -0,0 +1,26 @@
+---
+"@objectstack/spec": minor
+"@objectstack/rest": minor
+---
+
+`GET /api/v1/meta/:type/:name` serves a per-column sortability projection
+beside every object document (#10235, 2026-08-23 ruling, option A: the
+platform serves an explicit signal; grids never re-derive "virtual ⇒
+unsortable" from field type). The envelope gains an optional `sortability`
+key — present exactly when the served type is `object`, on every branch,
+cached included — declared by the new `ObjectSortabilitySchema` /
+`resolveObjectSortability` in `@objectstack/spec/api` and computed at serve
+time from the served (post-masking) document via the spec's own storage
+predicates, so the signal cannot drift from what the runtime doors
+(#6994/#7095) refuse. The closed category set: an unknown name and a dotted
+path are encoded as ABSENCE from the projection (both refused `400
+INVALID_SORT`), a virtual-type field (`SEARCH_VIRTUAL_TYPES` — `formula`
+today) answers `sortable: false, reason: 'virtual-type'` (refused at both
+doors), and a platform-injected anchor on an ADR-0015 `external` object with
+no storage behind it stays `sortable: true` with
+`caveat: 'unprovisioned-anchor'` (accepted by the doors, measured to degrade
+silently when the remote lacks the column — #10474). `summary` and
+`autonumber` stay sortable: virtuality is the storage predicate, never the
+write contract. The projection is deliberately NOT an authorable key — the
+served document is untouched. Consumer leg (the grid dropping the sort
+affordance where the signal says unsortable) is objectui#5729.
diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx
index 119f888674..8f56d9de8f 100644
--- a/content/docs/getting-started/quick-reference.mdx
+++ b/content/docs/getting-started/quick-reference.mdx
@@ -129,7 +129,7 @@ AI/ML capabilities - agents, skills, tools, MCP exposure, RAG, and cost tracking
| **[Usage](/docs/references/ai/usage)** | `usage.zod.ts` | AIUsageRecord, TokenUsage | AI usage and cost tracking |
| **[Solution Blueprint](/docs/references/ai/solution-blueprint)** | `solution-blueprint.zod.ts` | BlueprintObject, BlueprintApp | Blueprint format for AI app generation |
-## API Protocol (17 of 28 schemas)
+## API Protocol (17 of 29 schemas)
REST endpoints, real-time subscriptions, and discovery.
diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx
index 8ed3b4062a..080770244d 100644
--- a/content/docs/references/api/index.mdx
+++ b/content/docs/references/api/index.mdx
@@ -31,6 +31,7 @@ This section contains all protocol schemas for the api layer of ObjectStack.
+
diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json
index 07a1304afe..e333f891f8 100644
--- a/content/docs/references/api/meta.json
+++ b/content/docs/references/api/meta.json
@@ -32,6 +32,7 @@
"plugin-rest-api",
"storage",
"---More---",
- "error-code-ledger"
+ "error-code-ledger",
+ "sortability"
]
}
\ No newline at end of file
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index 3a11679176..476c33afb1 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -768,6 +768,7 @@ Enable package response
| **type** | `string` | ✅ | Metadata type name |
| **name** | `string` | ✅ | Item name |
| **item** | `any` | ✅ | Metadata item definition |
+| **sortability** | `{ fields: Record }` | optional | Per-column sortability projection (#10235) — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. |
| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` refuse the corresponding write with 403 `ITEM_LOCKED`. Resolved from the document's `_lock`, with the packaged artifact winning over any org overlay. |
| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. |
| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. |
diff --git a/content/docs/references/api/sortability.mdx b/content/docs/references/api/sortability.mdx
new file mode 100644
index 0000000000..e265ff4117
--- /dev/null
+++ b/content/docs/references/api/sortability.mdx
@@ -0,0 +1,134 @@
+---
+title: Sortability
+description: Sortability protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+[#10235] The per-column SORTABILITY projection served with object metadata —
+the one signal a grid reads to decide whether a column header offers a sort
+click, ruled 2026-08-23 (option A on #10235): the platform serves an explicit
+signal, and no consumer re-derives "virtual ⇒ unsortable" from field type.
+
+## Why a served projection, and why it is NOT an authorable key
+
+The shipped grids offered sort clicks the platform cannot honor: a sort on a
+`formula` column returned `asc` and `desc` in byte-identical order under a
+200 (#6994's measurement), and since #9313/#10234 the same click is refused
+loudly (`400 INVALID_SORT`) when the console persists it. The grid needs to
+know *before offering the click* — but teaching it "formula means
+unsortable" would re-implement the runtime's predicate one repo away, the
+shadow-copy drift this ruling exists to end.
+
+So the signal is COMPUTED at serve time from the same spec predicates the
+runtime doors and the authoring linter already read, and served beside the
+document on the `GET /meta/:type/:name` envelope. It is deliberately not a
+key inside the document: `FieldSchema` is `strictObject`, so an undeclared
+key on a served field is rejected by name — and declaring it would make it
+AUTHORABLE, handing authors a switch the runtime does not read (the exact
+declared-≠-enforced shape `resolveInjectedColumnProvenance`'s docblock
+records as deliberately rejected).
+
+## The category set, enumerated (closed against the runtime doors)
+
+"Unsortable" is judged by what the two runtime doors —
+`assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and
+`assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually
+do with an `orderBy` over the name. Three verdicts are REFUSALS, and one
+measured degradation is not refused; the projection covers all four:
+
+1. **Unknown name** — not a field of the object. Refused (`400
+ INVALID_SORT`). Encoded as ABSENCE: the projection's domain is exactly
+ the served field map plus the always-provisioned `id`, so a name with no
+ entry has no platform sort behind it and gets no affordance.
+2. **Dotted path** (`account.name`) — crosses into a related record no
+ driver joins for. Refused. Encoded as absence too: entries are keyed by
+ whole-column field names, and a dotted name can never appear as one.
+3. **Virtual type** (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`
+ — exactly `formula` today) — computed on read, no driver materialises a
+ column. Refused at both doors. Encoded as `sortable: false` with
+ `reason: 'virtual-type'`. This is the ONE per-field refusal fact, and it
+ is judged by the same spec predicate the linter
+ (`validate-sortable-fields`) and the search axis read — never by a local
+ type list.
+4. **Unprovisioned injected anchor** (`unprovisionedInjectedColumns`
+ — the platform's own injected columns on an ADR-0015 `external` object,
+ which registers them and provisions no storage). NOT refused: both doors
+ key on `formula` alone, so the sort reaches the driver, finds no column,
+ and is silently dropped — measured (#10474) as `asc` === `desc` under a
+ 200 while a real column reverses. The platform cannot *prove* the verdict
+ either way (the remote table may genuinely carry a column of that name),
+ so the entry stays `sortable: true` — the enforcement fact — and carries
+ `caveat: 'unprovisioned-anchor'` so a consumer can choose a conservative
+ affordance without re-deriving federation from the document.
+
+## Considered and deliberately NOT members
+
+- `summary` / `autonumber` — the other two `COMPUTED_VALUE_TYPES`. They sort
+ CORRECTLY (`summary` is an engine-maintained `table.float`, `autonumber`
+ an engine-assigned `table.string`; measured on #6924), which is exactly
+ why virtuality is judged by the storage predicate and never by the write
+ contract — widening would refuse the two types that work.
+- `encrypted` / `secret` / `json` / `vector` and the other heavy or masked
+ types — every one has a stored column, neither door refuses an ORDER BY
+ over one, and the drivers execute it. Marking them unsortable here would
+ invent a refusal the runtime does not enforce: the mirror image of the
+ declared-≠-enforced drift this signal exists to end. If any of them should
+ be refused, that is a runtime-door decision first, and this projection
+ follows it automatically through the shared predicate.
+- `created_at` / `updated_at` on an object that opted out of audit injection
+ (`systemFields: false`): the ingress gate hard-admits both names, but no
+ column is provisioned. They are simply absent from the served field map,
+ so they get no entry — the projection is allowed to be NARROWER than the
+ gate where the gate itself is known to degrade.
+
+## Contract for consumers (the objectui grid is the first)
+
+Offer a sort affordance on a column iff the projection has an entry for the
+column's field name and that entry says `sortable: true`. Absence means "no
+platform sort behind this name" — never "assume sortable". Do not recompute
+any of this from field `type` client-side; the verdicts here are derived
+from the same predicates the runtime enforces with, which is the whole
+point (the same "derived verdict — do not recompute" contract the
+protection envelope's `editable` carries).
+
+
+**Source:** `packages/spec/src/api/sortability.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { FieldSortabilitySchema, ObjectSortabilitySchema } from '@objectstack/spec/api';
+import type { FieldSortability, ObjectSortability } from '@objectstack/spec/api';
+
+// Validate data
+const result = FieldSortabilitySchema.parse(data);
+```
+
+---
+
+## FieldSortability
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **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. |
+| **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. |
+| **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. |
+
+
+---
+
+## ObjectSortability
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **fields** | `Record` | ✅ | Verdict per sortable-addressable column, keyed by field name. The domain is the served field map plus the always-provisioned `id`; a name absent from this map (an unknown field, a dotted path, an unprovisioned audit column) has no platform sort behind it and must get no sort affordance. |
+
+
+---
+
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 0241c62d32..7d7d76d704 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,6 +1,6 @@
---
title: Protocol Reference
-description: Every schema published by @objectstack/spec — 1583 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1585 schemas across 14 protocol modules
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with
| Module | Pages | Schemas | Description |
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
-| [API Protocol](/docs/references/api) | 28 | 415 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. |
+| [API Protocol](/docs/references/api) | 29 | 417 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **198** | **1583** | 14 protocol modules |
+| **Total** | **199** | **1585** | 14 protocol modules |
---
@@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations.
## API Protocol
-**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **28 pages, 415 schemas**
+**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **29 pages, 417 schemas**
REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery.
@@ -92,6 +92,7 @@ REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery.
| [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` |
| [`rest-server.zod.ts`](/docs/references/api/rest-server) | `BatchEndpointsConfig`, `CrudEndpointPattern`, `CrudEndpointsConfig`, `CrudOperation`, `EndpointRegistry`, `GeneratedEndpoint`, `MetadataEndpointsConfig`, `RestApiConfig`, `RestServerConfig`, `RouteGenerationConfig` |
| [`router.zod.ts`](/docs/references/api/router) | `ConflictResolutionStrategy`, `HttpMethod`, `RouteCategory`, `RouteDefinition`, `RouterConfig` |
+| [`sortability.zod.ts`](/docs/references/api/sortability) | `FieldSortability`, `ObjectSortability` |
| [`storage.zod.ts`](/docs/references/api/storage) | `CompleteChunkedUploadRequest`, `CompleteChunkedUploadResponse`, `CompleteUploadRequest`, `FileDownloadUrlResponse`, `FileTypeValidation`, `FileUploadResponse`, `GetPresignedUrlRequest`, `InitiateChunkedUploadRequest`, `InitiateChunkedUploadResponse`, `PresignedUrlResponse`, `RawUploadResponse`, `UploadChunkRequest`, `UploadChunkResponse`, `UploadProgress` |
| [`versioning.zod.ts`](/docs/references/api/versioning) | `VersionDefinition`, `VersionNegotiationResponse`, `VersionStatus`, `VersioningConfig`, `VersioningStrategy` |
| [`websocket.zod.ts`](/docs/references/api/websocket) | `AckMessage`, `CursorMessage`, `CursorPosition`, `DocumentState`, `EditMessage`, `EditOperation`, `EditOperationType`, `ErrorMessage`, `EventMessage`, `EventPattern`, `EventSubscription`, `PingMessage`, `PongMessage`, `PresenceMessage`, `PresenceState`, `PresenceUpdate`, `SimpleCursorPosition`, `SimplePresenceState`, `SubscribeMessage`, `UnsubscribeMessage`, `UnsubscribeRequest`, `WebSocketConfig`, `WebSocketEvent`, `WebSocketMessage`, `WebSocketMessageType`, `WebSocketPresenceStatus`, `WebSocketServerConfig` |
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
index a7a538038d..e0165a3208 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
@@ -257,7 +257,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
| `ai/` | 77 |
-| `api/` | 407 |
+| `api/` | 409 |
| `cloud/` | 83 |
| `identity/` | 32 |
| `integration/` | 10 |
diff --git a/examples/app-crm/test/sortability-projection-oracle.test.ts b/examples/app-crm/test/sortability-projection-oracle.test.ts
new file mode 100644
index 0000000000..e5884bca0d
--- /dev/null
+++ b/examples/app-crm/test/sortability-projection-oracle.test.ts
@@ -0,0 +1,52 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#10235] The measured oracle, pinned against the SHIPPED corpus: the two
+ * `crm_opportunity.expected_revenue` refusal cells (#9313/#10234's harness —
+ * the only column-sort PUTs over this app's displayed columns that answer
+ * `422 sort-field-unsortable`) must come out UNSORTABLE in the served
+ * sortability projection, so the downstream grid (objectui leg) can make the
+ * click unofferable.
+ *
+ * Pinned here — in the app that ships the cells — rather than against an
+ * inlined copy, so the oracle cannot drift from the corpus: if the view stops
+ * displaying the formula column, or the field stops being a formula, the
+ * premise assertions below go red rather than the pin going vacuously green.
+ */
+
+import { describe, it, expect } from 'vitest';
+import stack from '../objectstack.config.js';
+import { resolveObjectSortability } from '@objectstack/spec/api';
+
+const readColumns = (view: any): string[] =>
+ (view?.columns ?? []).map((c: any) => (typeof c === 'string' ? c : c?.field)).filter(Boolean);
+
+describe('#10235 sortability oracle — crm_opportunity.expected_revenue', () => {
+ const opportunity = (stack.objects ?? []).find((o: any) => o.name === 'crm_opportunity');
+ const aggregate: any = (stack.views ?? []).find(
+ (v: any) => (v as any)?.list?.data?.object === 'crm_opportunity',
+ );
+
+ it('premise: the formula column is DISPLAYED in both measured views', () => {
+ // The two refusal cells from the #9313/#10234 measurement: the aggregate's
+ // default list and its named `all` list view both ship the column.
+ expect(aggregate).toBeDefined();
+ expect(readColumns(aggregate.list)).toContain('expected_revenue');
+ expect(readColumns(aggregate.listViews?.all)).toContain('expected_revenue');
+ // And it IS a formula field on the shipped object — the category the
+ // runtime doors refuse (#6994/#7095).
+ expect((opportunity as any)?.fields?.expected_revenue?.type).toBe('formula');
+ });
+
+ it('the served projection marks the cell unsortable', () => {
+ const { fields } = resolveObjectSortability(opportunity);
+ expect(fields.expected_revenue).toEqual({ sortable: false, reason: 'virtual-type' });
+ });
+
+ it('anti-vacuity: the ordinary persisted columns beside it stay sortable', () => {
+ const { fields } = resolveObjectSortability(opportunity);
+ expect(fields.amount).toEqual({ sortable: true });
+ expect(fields.close_date).toEqual({ sortable: true });
+ expect(fields.name).toEqual({ sortable: true });
+ });
+});
diff --git a/examples/app-showcase/test/sortability-projection-oracle.test.ts b/examples/app-showcase/test/sortability-projection-oracle.test.ts
new file mode 100644
index 0000000000..80dec1a78f
--- /dev/null
+++ b/examples/app-showcase/test/sortability-projection-oracle.test.ts
@@ -0,0 +1,41 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#10235] The measured oracle, pinned against the SHIPPED corpus: the
+ * `showcase_project.budget_remaining` refusal cell (#9313/#10234's harness —
+ * the third of the 3 column-sort PUTs over shipped displayed columns that
+ * answer `422 sort-field-unsortable`) must come out UNSORTABLE in the served
+ * sortability projection, so the downstream grid (objectui leg) can make the
+ * click unofferable. The CRM twin pins the other two cells.
+ */
+
+import { describe, it, expect } from 'vitest';
+import stack from '../objectstack.config.js';
+import { resolveObjectSortability } from '@objectstack/spec/api';
+
+const readColumns = (view: any): string[] =>
+ (view?.columns ?? []).map((c: any) => (typeof c === 'string' ? c : c?.field)).filter(Boolean);
+
+describe('#10235 sortability oracle — showcase_project.budget_remaining', () => {
+ const project = (stack.objects ?? []).find((o: any) => o.name === 'showcase_project');
+ const aggregate: any = (stack.views ?? []).find(
+ (v: any) => (v as any)?.list?.data?.object === 'showcase_project',
+ );
+
+ it('premise: the formula column is DISPLAYED in the measured view', () => {
+ expect(aggregate).toBeDefined();
+ expect(readColumns(aggregate.list)).toContain('budget_remaining');
+ expect((project as any)?.fields?.budget_remaining?.type).toBe('formula');
+ });
+
+ it('the served projection marks the cell unsortable', () => {
+ const { fields } = resolveObjectSortability(project);
+ expect(fields.budget_remaining).toEqual({ sortable: false, reason: 'virtual-type' });
+ });
+
+ it('anti-vacuity: the persisted budget column beside it stays sortable', () => {
+ const { fields } = resolveObjectSortability(project);
+ expect(fields.budget).toEqual({ sortable: true });
+ expect(fields.spent).toEqual({ sortable: true });
+ });
+});
diff --git a/packages/rest/src/meta-object-sortability-projection.test.ts b/packages/rest/src/meta-object-sortability-projection.test.ts
new file mode 100644
index 0000000000..53a5937c26
--- /dev/null
+++ b/packages/rest/src/meta-object-sortability-projection.test.ts
@@ -0,0 +1,173 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#10235] `GET /meta/:type/:name` serves the per-column sortability
+ * projection beside the object document — on EVERY branch (2026-08-23 ruling,
+ * option A: the platform serves the signal; the grid never re-derives
+ * "virtual ⇒ unsortable" from field type).
+ *
+ * Why the branch coverage is the load-bearing part: the ADR-0010 protection
+ * keys are famously absent on the cached branch (THE DEFAULT — `enableCache`
+ * defaults to `true`), because they come from the lock resolver the cached
+ * path never consults. A sortability signal with that presence profile would
+ * be a half-signal: the default deployment's grid would never see it. The
+ * projection is therefore computed in `translateMetaEnvelope` — the one seam
+ * every single-item exit passes through (#5563) — from the served document
+ * itself, and this file pins it on both branches.
+ *
+ * AGREEMENT over hardcoding (the `meta-object-search-companion-agreement`
+ * posture): each host also asserts the served projection deep-equals
+ * `resolveObjectSortability()` — the spec resolver the runtime
+ * doors' predicate feeds — so the pin follows the predicate rather than a
+ * copied verdict table.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { resolveObjectSortability } from '@objectstack/spec/api';
+import { RestServer } from './rest-server.js';
+
+const ANON_API = { api: { requireAuth: false } };
+
+/**
+ * The #10235 oracle shape: a grid-displayed formula column beside ordinary
+ * persisted ones — `crm_opportunity.expected_revenue`'s structure, inlined.
+ */
+const OPPORTUNITY = {
+ name: 'crm_opportunity',
+ label: 'Opportunity',
+ fields: {
+ name: { type: 'text' },
+ amount: { type: 'currency' },
+ expected_revenue: { type: 'formula', expression: 'amount * probability / 100' },
+ },
+};
+
+const LIST_VIEW = {
+ name: 'opportunity_all',
+ object: 'crm_opportunity',
+ viewKind: 'list',
+ columns: ['name', 'amount', 'expected_revenue'],
+};
+
+function mockServer() {
+ return {
+ get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
+ use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
+ };
+}
+
+function mockRes() {
+ return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() };
+}
+
+/** Protocol double speaking the producer's real envelope (see meta-item-envelope.test.ts). */
+function baseProtocol(overrides: Record = {}) {
+ return {
+ getDiscovery: vi.fn().mockResolvedValue({
+ version: 'v0',
+ routes: { data: '', metadata: '', ui: '', auth: '/auth' },
+ }),
+ getMetaTypes: vi.fn().mockResolvedValue([]),
+ getMetaItems: vi.fn().mockResolvedValue([]),
+ getMetaItem: vi.fn(async ({ type, name }: any) => ({
+ // The real producer folds the plural URL spelling to the canonical
+ // singular (#4432) — mirror it, so the plural-spelling host below
+ // exercises the server against the envelope the producer actually
+ // speaks rather than against a double that skipped the fold.
+ type: ({ objects: 'object', views: 'view' } as Record)[type] ?? type,
+ name,
+ item: name === 'opportunity_all' ? LIST_VIEW : OPPORTUNITY,
+ lock: 'none', editable: true, deletable: true, resettable: false,
+ })),
+ getMetaItemCached: undefined as any,
+ findData: vi.fn().mockResolvedValue([]),
+ getData: vi.fn().mockResolvedValue({}),
+ createData: vi.fn().mockResolvedValue({ id: '1' }),
+ updateData: vi.fn().mockResolvedValue({}),
+ deleteData: vi.fn().mockResolvedValue({ success: true }),
+ ...overrides,
+ };
+}
+
+function itemRoute(rest: RestServer) {
+ return (rest as any).getRoutes().find(
+ (r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name',
+ );
+}
+
+async function dispatch(protocol: any, params: any, query: any = {}, config: any = ANON_API) {
+ const rest = new RestServer(mockServer() as any, protocol as any, config as any);
+ (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] });
+ rest.registerRoutes();
+ const res = mockRes();
+ await itemRoute(rest)!.handler({ params, query, headers: {} }, res);
+ // Indexed rather than `.at(-1)`: this package's TEST_DEBT re-measure runs
+ // under the repo tsconfig's lib, where `Array.prototype.at` is not typed —
+ // the ledger is shrink-only, so new test code stays off the untyped API.
+ const calls = res.json.mock.calls;
+ return { res, body: calls[calls.length - 1]?.[0] };
+}
+
+describe('#10235 GET /meta/object/:name — the sortability projection rides the envelope', () => {
+ it('non-cached branch: formula unsortable, persisted columns sortable, id appended', async () => {
+ const { body } = await dispatch(baseProtocol(), { type: 'object', name: 'crm_opportunity' });
+
+ expect(body.type).toBe('object');
+ expect(body.sortability).toBeDefined();
+ const fields = body.sortability.fields;
+ // The refusal-backed cell (#6994/#7095: 400 INVALID_SORT).
+ expect(fields.expected_revenue).toEqual({ sortable: false, reason: 'virtual-type' });
+ // Anti-vacuity: the projection must not go green by refusing everything.
+ expect(fields.name).toEqual({ sortable: true });
+ expect(fields.amount).toEqual({ sortable: true });
+ // The driver-provisioned primary key is addressable and sortable.
+ expect(fields.id).toEqual({ sortable: true });
+ // AGREEMENT: the wire projection IS the spec resolver's verdict over
+ // the served document — no second verdict table anywhere.
+ expect(body.sortability).toEqual(resolveObjectSortability(body.item));
+ });
+
+ it('cached branch (THE DEFAULT) serves the same projection — unlike the lock keys', async () => {
+ const protocol = baseProtocol({
+ getMetaItemCached: vi.fn().mockResolvedValue({
+ // `metadata-protocol.getMetaItemCached` hands back the
+ // already-unwrapped document; the REST layer rebuilds the envelope.
+ data: OPPORTUNITY,
+ etag: { value: 'abc', weak: false },
+ cacheControl: { directives: ['private', 'no-cache'] },
+ notModified: false,
+ }),
+ });
+
+ const { body } = await dispatch(protocol, { type: 'object', name: 'crm_opportunity' });
+
+ expect(protocol.getMetaItemCached).toHaveBeenCalled();
+ // The cached branch deliberately serves no `lock` — establishing that
+ // this test really is on the cached branch...
+ expect(body.lock).toBeUndefined();
+ // ...where the sortability projection must STILL be present: it is
+ // computed from the served document, not from the lock resolver.
+ expect(body.sortability).toEqual(resolveObjectSortability(OPPORTUNITY));
+ expect(body.sortability.fields.expected_revenue).toEqual({
+ sortable: false, reason: 'virtual-type',
+ });
+ expect(body.sortability.fields.amount).toEqual({ sortable: true });
+ });
+
+ it('plural URL spelling reaches the same projection (type folding, #4432)', async () => {
+ const { body } = await dispatch(baseProtocol(), { type: 'objects', name: 'crm_opportunity' });
+ expect(body.type).toBe('object');
+ expect(body.sortability.fields.expected_revenue).toEqual({
+ sortable: false, reason: 'virtual-type',
+ });
+ });
+
+ it('a non-object read carries NO sortability key', async () => {
+ // The projection is an OBJECT-schema fact; a view read must not grow a
+ // vacuous `sortability: { fields: {} }` that consumers then read as
+ // "this view's columns are all unknown".
+ const { body } = await dispatch(baseProtocol(), { type: 'view', name: 'opportunity_all' });
+ expect(body.item).toMatchObject({ name: 'opportunity_all' });
+ expect(body.sortability).toBeUndefined();
+ });
+});
diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts
index 1a02afaf33..94599a44ca 100644
--- a/packages/rest/src/rest-server.ts
+++ b/packages/rest/src/rest-server.ts
@@ -78,6 +78,12 @@ import type { ErrorCode } from '@objectstack/spec/api';
// TSDoc is its public statement (#6535). rest is the only enforcer, so it reads
// that export rather than re-declaring the literal beside a "mirrors spec" comment.
import { IMPORT_JOB_MAX_ROWS } from '@objectstack/spec/api';
+// [#10235] The per-column sortability projection the object read serves on its
+// envelope (2026-08-23 ruling, option A): computed HERE, at the one seam every
+// single-item read path rebuilds its body through, from the spec's own storage
+// predicates — so the signal the grid reads cannot drift from what the runtime
+// doors (#6994/#7095) refuse.
+import { resolveObjectSortability } from '@objectstack/spec/api';
import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security';
import { PLURAL_TO_SINGULAR, canonicalMetaUrlType, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared';
import { stripReadDecorations } from '@objectstack/spec/kernel';
@@ -2592,8 +2598,22 @@ export class RestServer {
document: any,
i18nService?: any,
): Promise {
+ // [#10235] The per-column sortability projection, served beside the
+ // document whenever the document IS an object schema. Computed here —
+ // the one seam every single-item exit passes through (#5563: cached,
+ // uncached, compound-name) — so the signal cannot be a property of
+ // which branch answered; and computed from the FINAL document (post
+ // ADR-0106 masking), so its domain is exactly the field set this
+ // caller is served. Never inside `item`: `FieldSchema` is strict and
+ // the key must stay un-authorable (see `sortability.zod.ts`).
+ const sortability =
+ RestServer.metaTypeSingular(type) === 'object'
+ && document && typeof document === 'object' && !Array.isArray(document)
+ ? { sortability: resolveObjectSortability(document) }
+ : {};
return {
...envelope,
+ ...sortability,
item: await this.translateMetaItem(req, type, environmentId, document, i18nService),
};
}
diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json
index af93d4b8f3..9633dad573 100644
--- a/packages/spec/api-surface/api.json
+++ b/packages/spec/api-surface/api.json
@@ -342,12 +342,16 @@
"ExportRequest (type)",
"ExportRequestParsed (type)",
"ExportRequestSchema (const)",
+ "FIELD_SORTABLE_UNPROVISIONED_ANCHOR (const)",
+ "FIELD_UNSORTABLE_VIRTUAL_TYPE (const)",
"FieldError (type)",
"FieldErrorCode (type)",
"FieldErrorSchema (const)",
"FieldMappingEntry (type)",
"FieldMappingEntryParsed (type)",
"FieldMappingEntrySchema (const)",
+ "FieldSortability (type)",
+ "FieldSortabilitySchema (const)",
"FileDownloadUrlResponse (type)",
"FileDownloadUrlResponseParsed (type)",
"FileDownloadUrlResponseSchema (const)",
@@ -679,6 +683,8 @@
"ObjectDefinitionResponse (type)",
"ObjectDefinitionResponseParsed (type)",
"ObjectDefinitionResponseSchema (const)",
+ "ObjectSortability (type)",
+ "ObjectSortabilitySchema (const)",
"OpenApiGenerationConfig (type)",
"OpenApiGenerationConfigParsed (type)",
"OpenApiGenerationConfigSchema (const)",
@@ -1005,6 +1011,7 @@
"normalizeEndpointPath (function)",
"readServiceSelfInfo (function)",
"resolveDiscoveryEnvironment (function)",
+ "resolveObjectSortability (function)",
"standardErrorCodeForHttpStatus (function)",
"standardSynonymOf (function)",
"standardSynonymViolations (function)",
diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json
index f6233734f2..feafaf7eb4 100644
--- a/packages/spec/authorable-surface/api.json
+++ b/packages/spec/authorable-surface/api.json
@@ -620,6 +620,9 @@
"api/FieldMappingEntry:targetField",
"api/FieldMappingEntry:targetLabel",
"api/FieldMappingEntry:transform",
+ "api/FieldSortability:caveat",
+ "api/FieldSortability:reason",
+ "api/FieldSortability:sortable",
"api/FileDownloadUrlResponse:data",
"api/FileDownloadUrlResponse:error",
"api/FileDownloadUrlResponse:meta",
@@ -756,6 +759,7 @@
"api/GetMetaItemResponse:packageVersion",
"api/GetMetaItemResponse:provenance",
"api/GetMetaItemResponse:resettable",
+ "api/GetMetaItemResponse:sortability",
"api/GetMetaItemResponse:type",
"api/GetMetaItemsRequest:organizationId",
"api/GetMetaItemsRequest:packageId",
@@ -1179,6 +1183,7 @@
"api/ObjectDefinitionResponse:error",
"api/ObjectDefinitionResponse:meta",
"api/ObjectDefinitionResponse:success",
+ "api/ObjectSortability:fields",
"api/OpenApiGenerationConfig:apiVersion",
"api/OpenApiGenerationConfig:contact",
"api/OpenApiGenerationConfig:description",
diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json
index ac851d53c6..785387e75c 100644
--- a/packages/spec/export-origins/api.json
+++ b/packages/spec/export-origins/api.json
@@ -342,12 +342,16 @@
"ExportRequest": "src/api/contract.zod.ts#ExportRequest (type)",
"ExportRequestParsed": "src/api/contract.zod.ts#ExportRequestParsed (type)",
"ExportRequestSchema": "src/api/contract.zod.ts#ExportRequestSchema (const)",
+ "FIELD_SORTABLE_UNPROVISIONED_ANCHOR": "src/api/sortability.zod.ts#FIELD_SORTABLE_UNPROVISIONED_ANCHOR (const)",
+ "FIELD_UNSORTABLE_VIRTUAL_TYPE": "src/api/sortability.zod.ts#FIELD_UNSORTABLE_VIRTUAL_TYPE (const)",
"FieldError": "src/api/errors.zod.ts#FieldError (type)",
"FieldErrorCode": "src/api/errors.zod.ts#FieldErrorCode (type)",
"FieldErrorSchema": "src/api/errors.zod.ts#FieldErrorSchema (const)",
"FieldMappingEntry": "src/api/export.zod.ts#FieldMappingEntry (type)",
"FieldMappingEntryParsed": "src/api/export.zod.ts#FieldMappingEntryParsed (type)",
"FieldMappingEntrySchema": "src/api/export.zod.ts#FieldMappingEntrySchema (const)",
+ "FieldSortability": "src/api/sortability.zod.ts#FieldSortability (type)",
+ "FieldSortabilitySchema": "src/api/sortability.zod.ts#FieldSortabilitySchema (const)",
"FileDownloadUrlResponse": "src/api/storage.zod.ts#FileDownloadUrlResponse (type)",
"FileDownloadUrlResponseParsed": "src/api/storage.zod.ts#FileDownloadUrlResponseParsed (type)",
"FileDownloadUrlResponseSchema": "src/api/storage.zod.ts#FileDownloadUrlResponseSchema (const)",
@@ -679,6 +683,8 @@
"ObjectDefinitionResponse": "src/api/metadata.zod.ts#ObjectDefinitionResponse (type)",
"ObjectDefinitionResponseParsed": "src/api/metadata.zod.ts#ObjectDefinitionResponseParsed (type)",
"ObjectDefinitionResponseSchema": "src/api/metadata.zod.ts#ObjectDefinitionResponseSchema (const)",
+ "ObjectSortability": "src/api/sortability.zod.ts#ObjectSortability (type)",
+ "ObjectSortabilitySchema": "src/api/sortability.zod.ts#ObjectSortabilitySchema (const)",
"OpenApiGenerationConfig": "src/api/plugin-rest-api.zod.ts#OpenApiGenerationConfig (type)",
"OpenApiGenerationConfigParsed": "src/api/plugin-rest-api.zod.ts#OpenApiGenerationConfigParsed (type)",
"OpenApiGenerationConfigSchema": "src/api/plugin-rest-api.zod.ts#OpenApiGenerationConfigSchema (const)",
@@ -1005,6 +1011,7 @@
"normalizeEndpointPath": "src/api/endpoint.zod.ts#normalizeEndpointPath (function)",
"readServiceSelfInfo": "src/api/discovery.zod.ts#readServiceSelfInfo (function)",
"resolveDiscoveryEnvironment": "src/api/discovery.zod.ts#resolveDiscoveryEnvironment (function)",
+ "resolveObjectSortability": "src/api/sortability.zod.ts#resolveObjectSortability (function)",
"standardErrorCodeForHttpStatus": "src/api/errors.zod.ts#standardErrorCodeForHttpStatus (function)",
"standardSynonymOf": "src/api/error-code-ledger.zod.ts#standardSynonymOf (function)",
"standardSynonymViolations": "src/api/error-code-ledger.zod.ts#standardSynonymViolations (function)",
diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json
index d2d8bbef4f..9ad4ca4f19 100644
--- a/packages/spec/json-schema.manifest/api.json
+++ b/packages/spec/json-schema.manifest/api.json
@@ -146,6 +146,7 @@
"api/FieldError",
"api/FieldErrorCode",
"api/FieldMappingEntry",
+ "api/FieldSortability",
"api/FileDownloadUrlResponse",
"api/FileTypeValidation",
"api/FileUploadResponse",
@@ -282,6 +283,7 @@
"api/ODataQueryAdapter",
"api/ODataResponse",
"api/ObjectDefinitionResponse",
+ "api/ObjectSortability",
"api/OpenApiGenerationConfig",
"api/OpenApiSecurityScheme",
"api/OpenApiServer",
diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts
index 49c1374cee..d17481a7bf 100644
--- a/packages/spec/src/api/index.ts
+++ b/packages/spec/src/api/index.ts
@@ -46,6 +46,10 @@ export * from './errors.zod';
export { zodIssuesToFields } from './zod-issues-to-fields';
export * from './error-code-ledger.zod';
export * from './protocol.zod';
+// [#10235] Per-column sortability projection: the serve-time signal the object
+// metadata read publishes so grids never re-derive "virtual ⇒ unsortable" from
+// field type (2026-08-23 ruling, option A).
+export * from './sortability.zod';
export * from './rest-server.zod';
// `./registry.zod` (the `ApiRegistry` / `ApiEndpointRegistration` family) was
// RETIRED in #4939 — ADR-0049 enforce-or-remove. It was assembled only in
diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts
index 7557ab413a..333bcf526e 100644
--- a/packages/spec/src/api/protocol.zod.ts
+++ b/packages/spec/src/api/protocol.zod.ts
@@ -32,6 +32,9 @@ import {
MetadataProvenanceSchema,
} from '../kernel/metadata-protection.zod';
import { MetadataValidationResultSchema } from '../kernel/metadata-plugin.zod';
+// [#10235] The per-column sortability projection the object read serves on its
+// envelope — computed at serve time, never authorable; see sortability.zod.ts.
+import { ObjectSortabilitySchema } from './sortability.zod';
import {
ListPackagesRequestSchema,
ListPackagesResponseSchema,
@@ -395,11 +398,25 @@ const MetadataProtectionEnvelopeFields = {
* ⚠️ This is a DECLARATION change only — zero runtime behaviour is altered. That
* lock presence depends on a server-side cache setting is a separate, larger
* question (#5950 says so explicitly) and is deliberately NOT decided here.
+ *
+ * [#10235] `sortability` is the one envelope key that is NOT part of the
+ * protection family: the per-column sortability projection, present exactly
+ * when the served type is `object` (every branch — cached included, unlike the
+ * protection keys — because it is computed from the served document itself,
+ * never from the lock resolver). See `sortability.zod.ts` for the category
+ * set and the consumer contract.
*/
export const GetMetaItemResponseSchema = lazySchema(() => z.object({
type: z.string().describe('Metadata type name'),
name: z.string().describe('Item name'),
item: z.unknown().describe('Metadata item definition'),
+ sortability: ObjectSortabilitySchema.optional().describe(
+ 'Per-column sortability projection (#10235) — present exactly when `type` '
+ + 'is `object`, on every serving branch. Computed at serve time from the '
+ + 'served document via the spec\'s own storage predicates; consumers render '
+ + 'sort affordances from this signal and never re-derive it from field '
+ + '`type`. See `ObjectSortabilitySchema` for the closed category set.',
+ ),
...MetadataProtectionEnvelopeFields,
}));
diff --git a/packages/spec/src/api/sortability.test.ts b/packages/spec/src/api/sortability.test.ts
new file mode 100644
index 0000000000..a2dc3b9b6b
--- /dev/null
+++ b/packages/spec/src/api/sortability.test.ts
@@ -0,0 +1,184 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#10235] The per-column sortability projection — the serve-time signal that
+ * replaces every consumer-side "virtual ⇒ unsortable" re-derivation (2026-08-23
+ * ruling, option A).
+ *
+ * What is pinned, and why each pin is load-bearing:
+ *
+ * - **The projection tracks the predicate** — the unsortable verdict is looped
+ * over `SEARCH_VIRTUAL_TYPES` itself, so a driver growing a second virtual
+ * type widens the projection with NO edit here; and the two computed types
+ * that sort correctly (`summary` / `autonumber`, measured #6924) are pinned
+ * `sortable: true`, the over-widening that would refuse working sorts.
+ * - **Anti-vacuity** — ordinary persisted columns answer `sortable: true`, so
+ * the projection cannot go green by marking everything unsortable.
+ * - **The #7865 anchor caveat** — unprovisioned injected anchors on an
+ * ADR-0015 `external` object stay `sortable: true` (the doors ACCEPT the
+ * sort) and carry the caveat (the measured silent drop); an author-declared
+ * column of the same name is the author's and carries none.
+ * - **Wire validity** — every resolved projection parses under
+ * `ObjectSortabilitySchema`, the schema the REST envelope declares.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ resolveObjectSortability,
+ ObjectSortabilitySchema,
+ FIELD_UNSORTABLE_VIRTUAL_TYPE,
+ FIELD_SORTABLE_UNPROVISIONED_ANCHOR,
+} from './sortability.zod';
+import { SEARCH_VIRTUAL_TYPES } from '../data/search-fields';
+
+/** The #10235 oracle shape: a formula column displayed in a shipped grid. */
+const OPPORTUNITY_LIKE = {
+ name: 'crm_opportunity',
+ fields: {
+ name: { type: 'text' },
+ amount: { type: 'currency' },
+ expected_revenue: { type: 'formula', expression: 'amount * probability / 100' },
+ close_date: { type: 'date' },
+ },
+};
+
+describe('#10235 resolveObjectSortability — the closed category set', () => {
+ it('marks a formula column unsortable with the refusal-backed reason', () => {
+ const { fields } = resolveObjectSortability(OPPORTUNITY_LIKE);
+ expect(fields.expected_revenue).toEqual({
+ sortable: false,
+ reason: FIELD_UNSORTABLE_VIRTUAL_TYPE,
+ });
+ });
+
+ it('anti-vacuity: ordinary persisted columns are sortable', () => {
+ const { fields } = resolveObjectSortability(OPPORTUNITY_LIKE);
+ expect(fields.name).toEqual({ sortable: true });
+ expect(fields.amount).toEqual({ sortable: true });
+ expect(fields.close_date).toEqual({ sortable: true });
+ });
+
+ it('tracks the spec predicate: EVERY virtual type is unsortable, with no local type list', () => {
+ // Loop over the predicate's own vocabulary, so widening
+ // `SEARCH_VIRTUAL_TYPES` widens this pin — and the projection — together.
+ expect(SEARCH_VIRTUAL_TYPES.size).toBeGreaterThan(0);
+ for (const vtype of SEARCH_VIRTUAL_TYPES) {
+ const { fields } = resolveObjectSortability({
+ name: 'x',
+ fields: { v: { type: vtype } },
+ });
+ expect(fields.v).toEqual({ sortable: false, reason: FIELD_UNSORTABLE_VIRTUAL_TYPE });
+ }
+ });
+
+ it('does NOT widen to the write contract: summary and autonumber sort correctly', () => {
+ // The `COMPUTED_VALUE_TYPES` trap `validate-sortable-fields` and the
+ // ingress door both document: `summary` is an engine-maintained float
+ // column, `autonumber` an engine-assigned string column — refusing them
+ // is the false finding that makes the signal untrustworthy.
+ const { fields } = resolveObjectSortability({
+ name: 'x',
+ fields: {
+ total: { type: 'summary' },
+ seq: { type: 'autonumber' },
+ },
+ });
+ expect(fields.total).toEqual({ sortable: true });
+ expect(fields.seq).toEqual({ sortable: true });
+ });
+
+ it('appends the driver-provisioned id, and never clobbers a declared one', () => {
+ const { fields } = resolveObjectSortability(OPPORTUNITY_LIKE);
+ expect(fields.id).toEqual({ sortable: true });
+ // Degenerate but declared: an author field named `id` keeps its own verdict.
+ const declared = resolveObjectSortability({
+ name: 'x',
+ fields: { id: { type: 'formula' } },
+ });
+ expect(declared.fields.id).toEqual({ sortable: false, reason: FIELD_UNSORTABLE_VIRTUAL_TYPE });
+ });
+
+ it('reads both served field-map shapes (record and array)', () => {
+ const arrayShape = resolveObjectSortability({
+ name: 'x',
+ fields: [
+ { name: 'plain', type: 'text' },
+ { name: 'virtual', type: 'formula' },
+ ],
+ });
+ expect(arrayShape.fields.plain).toEqual({ sortable: true });
+ expect(arrayShape.fields.virtual).toEqual({ sortable: false, reason: FIELD_UNSORTABLE_VIRTUAL_TYPE });
+ });
+});
+
+describe('#10235 the #7865 anchor category — accepted, caveated, never refused', () => {
+ /** An ADR-0015 external object as the registry serves it: injected anchors present. */
+ const EXTERNAL = {
+ name: 'ext_customer',
+ external: { datasource: 'remote_pg', table: 'customers' },
+ fields: {
+ name: { type: 'text' },
+ // The platform's own injected anchors, byte-identical to the shipped
+ // definitions is not required for THIS derivation: absence from the
+ // authored map is the common pre-injection shape, and
+ // `unprovisionedInjectedColumns` answers from the document either way.
+ },
+ };
+
+ it('caveats the unprovisioned injected anchors on an external object', () => {
+ const { fields } = resolveObjectSortability(EXTERNAL);
+ // The audit family and owner/tenant anchors are registered with no
+ // storage behind them (#7865): accepted by both doors, silently dropped
+ // by the driver when the remote lacks the column (#10474's measurement).
+ expect(fields.created_at).toEqual({
+ sortable: true,
+ caveat: FIELD_SORTABLE_UNPROVISIONED_ANCHOR,
+ });
+ expect(fields.owner_id).toEqual({
+ sortable: true,
+ caveat: FIELD_SORTABLE_UNPROVISIONED_ANCHOR,
+ });
+ // The author's own mapped column is plainly sortable — no caveat.
+ expect(fields.name).toEqual({ sortable: true });
+ });
+
+ it('an author-DECLARED anchor name on an external object is the author\'s — no caveat', () => {
+ // #7859's direction: declaring the column vouches for the remote schema.
+ const { fields } = resolveObjectSortability({
+ name: 'ext_customer',
+ external: { datasource: 'remote_pg', table: 'customers' },
+ fields: {
+ created_at: { type: 'datetime', label: 'Remote creation time' },
+ },
+ });
+ expect(fields.created_at).toEqual({ sortable: true });
+ });
+
+ it('platform-provisioned objects carry no caveat on their anchors', () => {
+ const { fields } = resolveObjectSortability({
+ name: 'local_obj',
+ fields: {
+ created_at: { type: 'datetime' },
+ owner_id: { type: 'lookup' },
+ },
+ });
+ expect(fields.created_at).toEqual({ sortable: true });
+ expect(fields.owner_id).toEqual({ sortable: true });
+ });
+});
+
+describe('#10235 wire validity — the projection parses under its own schema', () => {
+ it.each([
+ ['oracle-shaped object', OPPORTUNITY_LIKE],
+ ['external object', {
+ name: 'ext_customer',
+ external: { datasource: 'remote_pg', table: 'customers' },
+ fields: { name: { type: 'text' } },
+ }],
+ ['fieldless document', { name: 'bare' }],
+ ])('%s', (_label, doc) => {
+ const projection = resolveObjectSortability(doc);
+ const parsed = ObjectSortabilitySchema.safeParse(projection);
+ expect(parsed.success).toBe(true);
+ });
+});
diff --git a/packages/spec/src/api/sortability.zod.ts b/packages/spec/src/api/sortability.zod.ts
new file mode 100644
index 0000000000..a454a928a4
--- /dev/null
+++ b/packages/spec/src/api/sortability.zod.ts
@@ -0,0 +1,221 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#10235] The per-column SORTABILITY projection served with object metadata —
+ * the one signal a grid reads to decide whether a column header offers a sort
+ * click, ruled 2026-08-23 (option A on #10235): the platform serves an explicit
+ * signal, and no consumer re-derives "virtual ⇒ unsortable" from field type.
+ *
+ * ## Why a served projection, and why it is NOT an authorable key
+ *
+ * The shipped grids offered sort clicks the platform cannot honor: a sort on a
+ * `formula` column returned `asc` and `desc` in byte-identical order under a
+ * 200 (#6994's measurement), and since #9313/#10234 the same click is refused
+ * loudly (`400 INVALID_SORT`) when the console persists it. The grid needs to
+ * know *before offering the click* — but teaching it "formula means
+ * unsortable" would re-implement the runtime's predicate one repo away, the
+ * shadow-copy drift this ruling exists to end.
+ *
+ * So the signal is COMPUTED at serve time from the same spec predicates the
+ * runtime doors and the authoring linter already read, and served beside the
+ * document on the `GET /meta/:type/:name` envelope. It is deliberately not a
+ * key inside the document: `FieldSchema` is `strictObject`, so an undeclared
+ * key on a served field is rejected by name — and declaring it would make it
+ * AUTHORABLE, handing authors a switch the runtime does not read (the exact
+ * declared-≠-enforced shape `resolveInjectedColumnProvenance`'s docblock
+ * records as deliberately rejected).
+ *
+ * ## The category set, enumerated (closed against the runtime doors)
+ *
+ * "Unsortable" is judged by what the two runtime doors —
+ * `assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and
+ * `assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually
+ * do with an `orderBy` over the name. Three verdicts are REFUSALS, and one
+ * measured degradation is not refused; the projection covers all four:
+ *
+ * 1. **Unknown name** — not a field of the object. Refused (`400
+ * INVALID_SORT`). Encoded as ABSENCE: the projection's domain is exactly
+ * the served field map plus the always-provisioned `id`, so a name with no
+ * entry has no platform sort behind it and gets no affordance.
+ * 2. **Dotted path** (`account.name`) — crosses into a related record no
+ * driver joins for. Refused. Encoded as absence too: entries are keyed by
+ * whole-column field names, and a dotted name can never appear as one.
+ * 3. **Virtual type** ({@link isVirtualSearchField} / `SEARCH_VIRTUAL_TYPES`
+ * — exactly `formula` today) — computed on read, no driver materialises a
+ * column. Refused at both doors. Encoded as `sortable: false` with
+ * `reason: 'virtual-type'`. This is the ONE per-field refusal fact, and it
+ * is judged by the same spec predicate the linter
+ * (`validate-sortable-fields`) and the search axis read — never by a local
+ * type list.
+ * 4. **Unprovisioned injected anchor** ({@link unprovisionedInjectedColumns}
+ * — the platform's own injected columns on an ADR-0015 `external` object,
+ * which registers them and provisions no storage). NOT refused: both doors
+ * key on `formula` alone, so the sort reaches the driver, finds no column,
+ * and is silently dropped — measured (#10474) as `asc` === `desc` under a
+ * 200 while a real column reverses. The platform cannot *prove* the verdict
+ * either way (the remote table may genuinely carry a column of that name),
+ * so the entry stays `sortable: true` — the enforcement fact — and carries
+ * `caveat: 'unprovisioned-anchor'` so a consumer can choose a conservative
+ * affordance without re-deriving federation from the document.
+ *
+ * ## Considered and deliberately NOT members
+ *
+ * - `summary` / `autonumber` — the other two `COMPUTED_VALUE_TYPES`. They sort
+ * CORRECTLY (`summary` is an engine-maintained `table.float`, `autonumber`
+ * an engine-assigned `table.string`; measured on #6924), which is exactly
+ * why virtuality is judged by the storage predicate and never by the write
+ * contract — widening would refuse the two types that work.
+ * - `encrypted` / `secret` / `json` / `vector` and the other heavy or masked
+ * types — every one has a stored column, neither door refuses an ORDER BY
+ * over one, and the drivers execute it. Marking them unsortable here would
+ * invent a refusal the runtime does not enforce: the mirror image of the
+ * declared-≠-enforced drift this signal exists to end. If any of them should
+ * be refused, that is a runtime-door decision first, and this projection
+ * follows it automatically through the shared predicate.
+ * - `created_at` / `updated_at` on an object that opted out of audit injection
+ * (`systemFields: false`): the ingress gate hard-admits both names, but no
+ * column is provisioned. They are simply absent from the served field map,
+ * so they get no entry — the projection is allowed to be NARROWER than the
+ * gate where the gate itself is known to degrade.
+ *
+ * ## Contract for consumers (the objectui grid is the first)
+ *
+ * Offer a sort affordance on a column iff the projection has an entry for the
+ * column's field name and that entry says `sortable: true`. Absence means "no
+ * platform sort behind this name" — never "assume sortable". Do not recompute
+ * any of this from field `type` client-side; the verdicts here are derived
+ * from the same predicates the runtime enforces with, which is the whole
+ * point (the same "derived verdict — do not recompute" contract the
+ * protection envelope's `editable` carries).
+ */
+
+import { z } from 'zod';
+import { lazySchema } from '../shared/lazy-schema';
+import { isVirtualSearchField, type SearchFieldMeta } from '../data/search-fields';
+import { unprovisionedInjectedColumns } from '../data/injected-system-column-provenance';
+
+/**
+ * The one refusal-backed per-field reason: the field's type is virtual
+ * (computed on read, no stored column — `SEARCH_VIRTUAL_TYPES`), so both
+ * runtime doors refuse an ORDER BY over it with `400 INVALID_SORT`.
+ */
+export const FIELD_UNSORTABLE_VIRTUAL_TYPE = 'virtual-type' as const;
+
+/**
+ * The one advisory caveat: the field is a platform-injected anchor on an
+ * ADR-0015 `external` object, registered with no storage behind it (#7865 /
+ * #10474). The sort is ACCEPTED by the runtime but is silently dropped when
+ * the remote table carries no such column — a degradation the platform cannot
+ * prove either way at serve time.
+ */
+export const FIELD_SORTABLE_UNPROVISIONED_ANCHOR = 'unprovisioned-anchor' as const;
+
+/**
+ * Sortability verdict for ONE field of a served object document.
+ */
+export const FieldSortabilitySchema = lazySchema(() => z.object({
+ sortable: z.boolean().describe(
+ '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.',
+ ),
+ reason: z.literal(FIELD_UNSORTABLE_VIRTUAL_TYPE).optional().describe(
+ '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.',
+ ),
+ caveat: z.literal(FIELD_SORTABLE_UNPROVISIONED_ANCHOR).optional().describe(
+ '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.',
+ ),
+}));
+
+// ADR-0122: the bare alias names the AUTHOR (input) state. This schema has no
+// defaults or transforms, so input and parsed coincide — the bare name is the
+// only alias the surface needs.
+export type FieldSortability = z.input;
+
+/**
+ * The per-column sortability projection for one object — served on the
+ * `GET /meta/:type/:name` envelope (beside `item`, never inside it) when the
+ * type is `object`. See the module docblock for the closed category set and
+ * the consumer contract.
+ */
+export const ObjectSortabilitySchema = lazySchema(() => z.object({
+ fields: z.record(z.string(), FieldSortabilitySchema).describe(
+ 'Verdict per sortable-addressable column, keyed by field name. The domain '
+ + 'is the served field map plus the always-provisioned `id`; a name absent '
+ + 'from this map (an unknown field, a dotted path, an unprovisioned audit '
+ + 'column) has no platform sort behind it and must get no sort affordance.',
+ ),
+}));
+
+// ADR-0122: bare = input state; no transforms here, so it is also the wire shape.
+export type ObjectSortability = z.input;
+
+/** Tolerant field-map reader — accepts both served `fields` shapes. */
+function fieldEntriesOf(doc: unknown): Array<[string, SearchFieldMeta | undefined]> {
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return [];
+ const fields = (doc as { fields?: unknown }).fields;
+ if (Array.isArray(fields)) {
+ // The array shape carries the name inside each entry.
+ const entries: Array<[string, SearchFieldMeta | undefined]> = [];
+ for (const f of fields) {
+ if (!f || typeof f !== 'object') continue;
+ const name = (f as { name?: unknown }).name;
+ if (typeof name === 'string' && name.length > 0) {
+ entries.push([name, f as SearchFieldMeta]);
+ }
+ }
+ return entries;
+ }
+ if (!fields || typeof fields !== 'object') return [];
+ return Object.entries(fields as Record).map(
+ ([name, meta]) => [name, (meta && typeof meta === 'object' ? meta : undefined) as SearchFieldMeta | undefined],
+ );
+}
+
+/**
+ * Derive the sortability projection for one object document.
+ *
+ * PURE and tolerant of bare / un-parsed metadata records (the same contract
+ * every derivation in `data/injected-system-column-provenance.ts` carries), so
+ * the REST serving layer can run it on the exact document it is about to serve
+ * — post-masking, post-injection — and the projection's domain equals what the
+ * caller can see. Judged by the spec's own predicates and nothing else:
+ *
+ * - virtuality by {@link isVirtualSearchField} (`SEARCH_VIRTUAL_TYPES`) — the
+ * same storage fact the runtime doors (#6994/#7095) and the authoring linter
+ * (`validate-sortable-fields`) key on;
+ * - anchor provenance by {@link unprovisionedInjectedColumns} — the #7865
+ * derivation the runtime guards and the linter converge on.
+ *
+ * `id` is appended when the document declares no field of that name: the
+ * primary key is provisioned by the DRIVER (never authored, never injected),
+ * and the ingress gate admits it unconditionally, so a grid column over it is
+ * genuinely sortable.
+ */
+export function resolveObjectSortability(doc: unknown): ObjectSortability {
+ const fields: Record = {};
+ for (const [name, meta] of fieldEntriesOf(doc)) {
+ fields[name] = isVirtualSearchField(meta)
+ ? { sortable: false, reason: FIELD_UNSORTABLE_VIRTUAL_TYPE }
+ : { sortable: true };
+ }
+ // The #7865 anchors with no storage: still accepted by the doors, so the
+ // enforcement fact stays `sortable: true` — the caveat is the measured
+ // degradation. `unprovisionedInjectedColumns` already excludes an
+ // author-declared column of the same name (the author vouches for the
+ // remote column — #7859's direction), and an injected anchor is never a
+ // virtual type, so this only ever annotates a `sortable: true` entry.
+ for (const name of unprovisionedInjectedColumns(doc)) {
+ fields[name] = { sortable: true, caveat: FIELD_SORTABLE_UNPROVISIONED_ANCHOR };
+ }
+ if (fields.id === undefined) fields.id = { sortable: true };
+ return { fields };
+}
diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts
index 38a7ecf190..8bbe89c848 100644
--- a/packages/spec/src/type-alias-convention.pin.test.ts
+++ b/packages/spec/src/type-alias-convention.pin.test.ts
@@ -263,9 +263,11 @@ import type * as M167 from './ui/view.zod.js';
// one. #5775 is the first entry from this file — `component.zod.ts` had no bare
// `X = z.infer` alias until `PageContainerProps` arrived.
import type * as M170 from './ui/component.zod.js';
+// [#10235] The served sortability projection — new module, next free index.
+import type * as M183 from './api/sortability.zod.js';
// ---------------------------------------------------------------------------
-// 834 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
+// 836 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
//
// That number is machine-checked, not hand-kept. The runtime companion at the
// bottom of this file recomputes the pin count from the source and asserts that
@@ -1590,6 +1592,12 @@ export type Iso851 = Assert, z.in
export type Iso830 = Assert, z.infer< typeof M167.NavigationModeSchema > >>;
export type Iso831 = Assert, z.infer< typeof M167.TreeConfigSchema > >>;
export type Iso832 = Assert, z.infer< typeof M167.ViewItemNameSchema > >>;
+
+// [#10235] api/sortability.zod.ts — the served projection carries no defaults
+// or transforms by design (it is a serve-time computation, never parsed from
+// an author), so input and parsed coincide and the bare aliases stand alone.
+export type Iso854 = Assert, z.infer< typeof M183.FieldSortabilitySchema > >>;
+export type Iso855 = Assert, z.infer< typeof M183.ObjectSortabilitySchema > >>;
// ---------------------------------------------------------------------------
// Representative spot-checks on the phase-2 FLIP.
//
@@ -1658,7 +1666,7 @@ describe('ADR-0122 type-alias convention', () => {
// this title and the section header above the pin list — are now asserted
// against the recomputed count below, so neither can go stale without a red
// test naming it.
- it('still declares all 834 isomorphic pins', () => {
+ it('still declares all 836 isomorphic pins', () => {
// The truth of each pin is proved by tsc, not here — an `Assert>`
// that stops holding is a compile error with the alias named. What tsc
// cannot notice is a pin that was DELETED: removing the assertion removes
@@ -1932,7 +1940,7 @@ describe('ADR-0122 type-alias convention', () => {
// The Iso numbers are positional and stay vacant.
const self = readFileSync(fileURLToPath(import.meta.url), 'utf8');
const pins = self.match(/^export type Iso\d+ = Assert