Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/meta-object-sortability-projection.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/getting-started/quick-reference.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/index.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ This section contains all protocol schemas for the api layer of ObjectStack.
<Card href="/docs/references/api/realtime-shared" title="Realtime Shared" description="Source: packages/spec/src/api/realtime-shared.zod.ts" />
<Card href="/docs/references/api/rest-server" title="Rest Server" description="Source: packages/spec/src/api/rest-server.zod.ts" />
<Card href="/docs/references/api/router" title="Router" description="Source: packages/spec/src/api/router.zod.ts" />
<Card href="/docs/references/api/sortability" title="Sortability" description="Source: packages/spec/src/api/sortability.zod.ts" />
<Card href="/docs/references/api/storage" title="Storage" description="Source: packages/spec/src/api/storage.zod.ts" />
<Card href="/docs/references/api/versioning" title="Versioning" description="Source: packages/spec/src/api/versioning.zod.ts" />
<Card href="/docs/references/api/websocket" title="Websocket" description="Source: packages/spec/src/api/websocket.zod.ts" />
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/meta.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@
"plugin-rest-api",
"storage",
"---More---",
"error-code-ledger"
"error-code-ledger",
"sortability"
]
}
1 change: 1 addition & 0 deletions content/docs/references/api/protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,6 +768,7 @@ Enable package response
| **type** | `string` | ✅ | Metadata type name |
| **name** | `string` | ✅ | Item name |
| **item** | `any` | ✅ | Metadata item definition |
| **sortability** | `{ fields: Record<string, object> }` | 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`. |
Expand Down
134 changes: 134 additions & 0 deletions content/docs/references/api/sortability.mdx
Original file line numberDiff line numberDiff line change
@@ -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).

<Callout type="info">
**Source:** `packages/spec/src/api/sortability.zod.ts`
</Callout>

## 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<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`; 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. |


---

9 changes: 5 additions & 4 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -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/. */}
Expand All@@ -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. |
Expand All@@ -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 |

---

Expand DownExpand Up@@ -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.

Expand DownExpand Up@@ -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` |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
| `ai/` | 77 |
| `api/` | 407 |
| `api/` | 409 |
| `cloud/` | 83 |
| `identity/` | 32 |
| `integration/` | 10 |
Expand Down
52 changes: 52 additions & 0 deletions examples/app-crm/test/sortability-projection-oracle.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 });
});
});
Loading
Loading