Skip to content

Commit e5ea701

Browse files
os-samclaude
andauthored
feat(spec,rest): serve a per-column sortability projection with object metadata (#11214)
* feat(spec,rest): serve a per-column sortability projection with object metadata Fixes #10235 (spec/rest leg; consumer leg is objectui#5729). 2026-08-23 ruling, option A: the platform serves an explicit per-column sortability signal with object metadata; the grid reads the signal and never re-derives 'virtual => unsortable' from field type. - packages/spec/src/api/sortability.zod.ts: ObjectSortabilitySchema + resolveObjectSortability, derived from the spec's own storage predicates (isVirtualSearchField / SEARCH_VIRTUAL_TYPES, unprovisionedInjectedColumns) so the signal cannot drift from what the runtime doors (#6994/#7095) refuse. The closed category set is enumerated in the module docblock. - GetMetaItemResponseSchema gains an optional envelope-level 'sortability' key (never inside the document: FieldSchema is strict and the key must stay un-authorable). - rest: computed in translateMetaEnvelope - the one seam every single-item read exit passes through - so the cached (default) branch serves it too, from the final post-masking document. - Tests: spec resolver pins (predicate-coupled via SEARCH_VIRTUAL_TYPES loop, summary/autonumber anti-overreach, anchor caveat), rest branch/agreement pins, and the shipped-corpus oracle pins in app-crm / app-showcase (crm_opportunity.expected_revenue x2 views, showcase_project.budget_remaining). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RadETjNRLALFLhFA3xehZP * fix(spec,rest): ADR-0122 alias state + gate-named count follow-ups for the sortability projection - FieldSortability / ObjectSortability flip to z.input (bare = author state) and join the isomorphic pin registry (no defaults/transforms by design). - quick-reference API Protocol page total 28 -> 29 (the new reference page). - rest test double now folds the plural URL spelling like the real producer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RadETjNRLALFLhFA3xehZP * fix(rest): keep the new projection test off the TEST_DEBT-ledgered tsc errors The @objectstack/rest test layer is gated by a shrink-only TEST_DEBT ledger (155 raw tsc errors); the new test file added 2 (TS2835 missing .js extension, TS2550 Array.prototype.at under the repo lib). Explicit extension + indexed access; re-measured 157 -> 155 with the ledger's own synthetic project. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RadETjNRLALFLhFA3xehZP --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41ae8d6 commit e5ea701

21 files changed

Lines changed: 915 additions & 10 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
`GET /api/v1/meta/:type/:name` serves a per-column sortability projection
7+
beside every object document (#10235, 2026-08-23 ruling, option A: the
8+
platform serves an explicit signal; grids never re-derive "virtual ⇒
9+
unsortable" from field type). The envelope gains an optional `sortability`
10+
key — present exactly when the served type is `object`, on every branch,
11+
cached included — declared by the new `ObjectSortabilitySchema` /
12+
`resolveObjectSortability` in `@objectstack/spec/api` and computed at serve
13+
time from the served (post-masking) document via the spec's own storage
14+
predicates, so the signal cannot drift from what the runtime doors
15+
(#6994/#7095) refuse. The closed category set: an unknown name and a dotted
16+
path are encoded as ABSENCE from the projection (both refused `400
17+
INVALID_SORT`), a virtual-type field (`SEARCH_VIRTUAL_TYPES``formula`
18+
today) answers `sortable: false, reason: 'virtual-type'` (refused at both
19+
doors), and a platform-injected anchor on an ADR-0015 `external` object with
20+
no storage behind it stays `sortable: true` with
21+
`caveat: 'unprovisioned-anchor'` (accepted by the doors, measured to degrade
22+
silently when the remote lacks the column — #10474). `summary` and
23+
`autonumber` stay sortable: virtuality is the storage predicate, never the
24+
write contract. The projection is deliberately NOT an authorable key — the
25+
served document is untouched. Consumer leg (the grid dropping the sort
26+
affordance where the signal says unsortable) is objectui#5729.

content/docs/getting-started/quick-reference.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ AI/ML capabilities - agents, skills, tools, MCP exposure, RAG, and cost tracking
129129
| **[Usage](/docs/references/ai/usage)** | `usage.zod.ts` | AIUsageRecord, TokenUsage | AI usage and cost tracking |
130130
| **[Solution Blueprint](/docs/references/ai/solution-blueprint)** | `solution-blueprint.zod.ts` | BlueprintObject, BlueprintApp | Blueprint format for AI app generation |
131131

132-
## API Protocol (17 of 28 schemas)
132+
## API Protocol (17 of 29 schemas)
133133

134134
REST endpoints, real-time subscriptions, and discovery.
135135

content/docs/references/api/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ This section contains all protocol schemas for the api layer of ObjectStack.
3131
<Card href="/docs/references/api/realtime-shared" title="Realtime Shared" description="Source: packages/spec/src/api/realtime-shared.zod.ts" />
3232
<Card href="/docs/references/api/rest-server" title="Rest Server" description="Source: packages/spec/src/api/rest-server.zod.ts" />
3333
<Card href="/docs/references/api/router" title="Router" description="Source: packages/spec/src/api/router.zod.ts" />
34+
<Card href="/docs/references/api/sortability" title="Sortability" description="Source: packages/spec/src/api/sortability.zod.ts" />
3435
<Card href="/docs/references/api/storage" title="Storage" description="Source: packages/spec/src/api/storage.zod.ts" />
3536
<Card href="/docs/references/api/versioning" title="Versioning" description="Source: packages/spec/src/api/versioning.zod.ts" />
3637
<Card href="/docs/references/api/websocket" title="Websocket" description="Source: packages/spec/src/api/websocket.zod.ts" />

content/docs/references/api/meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"plugin-rest-api",
3333
"storage",
3434
"---More---",
35-
"error-code-ledger"
35+
"error-code-ledger",
36+
"sortability"
3637
]
3738
}

content/docs/references/api/protocol.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,7 @@ Enable package response
768768
| **type** | `string` || Metadata type name |
769769
| **name** | `string` || Item name |
770770
| **item** | `any` || Metadata item definition |
771+
| **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. |
771772
| **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. |
772773
| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. |
773774
| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. |
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
---
2+
title: Sortability
3+
description: Sortability protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
[#10235] The per-column SORTABILITY projection served with object metadata —
9+
the one signal a grid reads to decide whether a column header offers a sort
10+
click, ruled 2026-08-23 (option A on #10235): the platform serves an explicit
11+
signal, and no consumer re-derives "virtual ⇒ unsortable" from field type.
12+
13+
## Why a served projection, and why it is NOT an authorable key
14+
15+
The shipped grids offered sort clicks the platform cannot honor: a sort on a
16+
`formula` column returned `asc` and `desc` in byte-identical order under a
17+
200 (#6994's measurement), and since #9313/#10234 the same click is refused
18+
loudly (`400 INVALID_SORT`) when the console persists it. The grid needs to
19+
know *before offering the click* — but teaching it "formula means
20+
unsortable" would re-implement the runtime's predicate one repo away, the
21+
shadow-copy drift this ruling exists to end.
22+
23+
So the signal is COMPUTED at serve time from the same spec predicates the
24+
runtime doors and the authoring linter already read, and served beside the
25+
document on the `GET /meta/:type/:name` envelope. It is deliberately not a
26+
key inside the document: `FieldSchema` is `strictObject`, so an undeclared
27+
key on a served field is rejected by name — and declaring it would make it
28+
AUTHORABLE, handing authors a switch the runtime does not read (the exact
29+
declared-≠-enforced shape `resolveInjectedColumnProvenance`'s docblock
30+
records as deliberately rejected).
31+
32+
## The category set, enumerated (closed against the runtime doors)
33+
34+
"Unsortable" is judged by what the two runtime doors —
35+
`assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) and
36+
`assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) — actually
37+
do with an `orderBy` over the name. Three verdicts are REFUSALS, and one
38+
measured degradation is not refused; the projection covers all four:
39+
40+
1. **Unknown name** — not a field of the object. Refused (`400
41+
INVALID_SORT`). Encoded as ABSENCE: the projection's domain is exactly
42+
the served field map plus the always-provisioned `id`, so a name with no
43+
entry has no platform sort behind it and gets no affordance.
44+
2. **Dotted path** (`account.name`) — crosses into a related record no
45+
driver joins for. Refused. Encoded as absence too: entries are keyed by
46+
whole-column field names, and a dotted name can never appear as one.
47+
3. **Virtual type** (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`
48+
— exactly `formula` today) — computed on read, no driver materialises a
49+
column. Refused at both doors. Encoded as `sortable: false` with
50+
`reason: 'virtual-type'`. This is the ONE per-field refusal fact, and it
51+
is judged by the same spec predicate the linter
52+
(`validate-sortable-fields`) and the search axis read — never by a local
53+
type list.
54+
4. **Unprovisioned injected anchor** (`unprovisionedInjectedColumns`
55+
— the platform's own injected columns on an ADR-0015 `external` object,
56+
which registers them and provisions no storage). NOT refused: both doors
57+
key on `formula` alone, so the sort reaches the driver, finds no column,
58+
and is silently dropped — measured (#10474) as `asc` === `desc` under a
59+
200 while a real column reverses. The platform cannot *prove* the verdict
60+
either way (the remote table may genuinely carry a column of that name),
61+
so the entry stays `sortable: true` — the enforcement fact — and carries
62+
`caveat: 'unprovisioned-anchor'` so a consumer can choose a conservative
63+
affordance without re-deriving federation from the document.
64+
65+
## Considered and deliberately NOT members
66+
67+
- `summary` / `autonumber` — the other two `COMPUTED_VALUE_TYPES`. They sort
68+
CORRECTLY (`summary` is an engine-maintained `table.float`, `autonumber`
69+
an engine-assigned `table.string`; measured on #6924), which is exactly
70+
why virtuality is judged by the storage predicate and never by the write
71+
contract — widening would refuse the two types that work.
72+
- `encrypted` / `secret` / `json` / `vector` and the other heavy or masked
73+
types — every one has a stored column, neither door refuses an ORDER BY
74+
over one, and the drivers execute it. Marking them unsortable here would
75+
invent a refusal the runtime does not enforce: the mirror image of the
76+
declared-≠-enforced drift this signal exists to end. If any of them should
77+
be refused, that is a runtime-door decision first, and this projection
78+
follows it automatically through the shared predicate.
79+
- `created_at` / `updated_at` on an object that opted out of audit injection
80+
(`systemFields: false`): the ingress gate hard-admits both names, but no
81+
column is provisioned. They are simply absent from the served field map,
82+
so they get no entry — the projection is allowed to be NARROWER than the
83+
gate where the gate itself is known to degrade.
84+
85+
## Contract for consumers (the objectui grid is the first)
86+
87+
Offer a sort affordance on a column iff the projection has an entry for the
88+
column's field name and that entry says `sortable: true`. Absence means "no
89+
platform sort behind this name" — never "assume sortable". Do not recompute
90+
any of this from field `type` client-side; the verdicts here are derived
91+
from the same predicates the runtime enforces with, which is the whole
92+
point (the same "derived verdict — do not recompute" contract the
93+
protection envelope's `editable` carries).
94+
95+
<Callout type="info">
96+
**Source:** `packages/spec/src/api/sortability.zod.ts`
97+
</Callout>
98+
99+
## TypeScript Usage
100+
101+
```typescript
102+
import { FieldSortabilitySchema, ObjectSortabilitySchema } from '@objectstack/spec/api';
103+
import type { FieldSortability, ObjectSortability } from '@objectstack/spec/api';
104+
105+
// Validate data
106+
const result = FieldSortabilitySchema.parse(data);
107+
```
108+
109+
---
110+
111+
## FieldSortability
112+
113+
### Properties
114+
115+
| Property | Type | Required | Description |
116+
| :--- | :--- | :--- | :--- |
117+
| **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. |
118+
| **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. |
119+
| **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. |
120+
121+
122+
---
123+
124+
## ObjectSortability
125+
126+
### Properties
127+
128+
| Property | Type | Required | Description |
129+
| :--- | :--- | :--- | :--- |
130+
| **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. |
131+
132+
133+
---
134+

content/docs/references/index.mdx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Protocol Reference
3-
description: Every schema published by @objectstack/spec — 1583 schemas across 14 protocol modules
3+
description: Every schema published by @objectstack/spec — 1585 schemas across 14 protocol modules
44
---
55

66
{/* ⚠️ 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
2020
| Module | Pages | Schemas | Description |
2121
| :--- | ---: | ---: | :--- |
2222
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
23-
| [API Protocol](/docs/references/api) | 28 | 415 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. |
23+
| [API Protocol](/docs/references/api) | 29 | 417 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. |
2424
| [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
2525
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
2626
| [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
3333
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3434
| [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3535
| [UI Protocol](/docs/references/ui) | 16 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
36-
| **Total** | **198** | **1583** | 14 protocol modules |
36+
| **Total** | **199** | **1585** | 14 protocol modules |
3737

3838
---
3939

@@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations.
6161

6262
## API Protocol
6363

64-
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **28 pages, 415 schemas**
64+
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **29 pages, 417 schemas**
6565

6666
REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery.
6767

@@ -92,6 +92,7 @@ REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery.
9292
| [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` |
9393
| [`rest-server.zod.ts`](/docs/references/api/rest-server) | `BatchEndpointsConfig`, `CrudEndpointPattern`, `CrudEndpointsConfig`, `CrudOperation`, `EndpointRegistry`, `GeneratedEndpoint`, `MetadataEndpointsConfig`, `RestApiConfig`, `RestServerConfig`, `RouteGenerationConfig` |
9494
| [`router.zod.ts`](/docs/references/api/router) | `ConflictResolutionStrategy`, `HttpMethod`, `RouteCategory`, `RouteDefinition`, `RouterConfig` |
95+
| [`sortability.zod.ts`](/docs/references/api/sortability) | `FieldSortability`, `ObjectSortability` |
9596
| [`storage.zod.ts`](/docs/references/api/storage) | `CompleteChunkedUploadRequest`, `CompleteChunkedUploadResponse`, `CompleteUploadRequest`, `FileDownloadUrlResponse`, `FileTypeValidation`, `FileUploadResponse`, `GetPresignedUrlRequest`, `InitiateChunkedUploadRequest`, `InitiateChunkedUploadResponse`, `PresignedUrlResponse`, `RawUploadResponse`, `UploadChunkRequest`, `UploadChunkResponse`, `UploadProgress` |
9697
| [`versioning.zod.ts`](/docs/references/api/versioning) | `VersionDefinition`, `VersionNegotiationResponse`, `VersionStatus`, `VersioningConfig`, `VersioningStrategy` |
9798
| [`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` |

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ directory rather than per file.
257257
| Dir | Sites |
258258
|---|---|
259259
| `ai/` | 77 |
260-
| `api/` | 407 |
260+
| `api/` | 409 |
261261
| `cloud/` | 83 |
262262
| `identity/` | 32 |
263263
| `integration/` | 10 |
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#10235] The measured oracle, pinned against the SHIPPED corpus: the two
5+
* `crm_opportunity.expected_revenue` refusal cells (#9313/#10234's harness —
6+
* the only column-sort PUTs over this app's displayed columns that answer
7+
* `422 sort-field-unsortable`) must come out UNSORTABLE in the served
8+
* sortability projection, so the downstream grid (objectui leg) can make the
9+
* click unofferable.
10+
*
11+
* Pinned here — in the app that ships the cells — rather than against an
12+
* inlined copy, so the oracle cannot drift from the corpus: if the view stops
13+
* displaying the formula column, or the field stops being a formula, the
14+
* premise assertions below go red rather than the pin going vacuously green.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import stack from '../objectstack.config.js';
19+
import { resolveObjectSortability } from '@objectstack/spec/api';
20+
21+
const readColumns = (view: any): string[] =>
22+
(view?.columns ?? []).map((c: any) => (typeof c === 'string' ? c : c?.field)).filter(Boolean);
23+
24+
describe('#10235 sortability oracle — crm_opportunity.expected_revenue', () => {
25+
const opportunity = (stack.objects ?? []).find((o: any) => o.name === 'crm_opportunity');
26+
const aggregate: any = (stack.views ?? []).find(
27+
(v: any) => (v as any)?.list?.data?.object === 'crm_opportunity',
28+
);
29+
30+
it('premise: the formula column is DISPLAYED in both measured views', () => {
31+
// The two refusal cells from the #9313/#10234 measurement: the aggregate's
32+
// default list and its named `all` list view both ship the column.
33+
expect(aggregate).toBeDefined();
34+
expect(readColumns(aggregate.list)).toContain('expected_revenue');
35+
expect(readColumns(aggregate.listViews?.all)).toContain('expected_revenue');
36+
// And it IS a formula field on the shipped object — the category the
37+
// runtime doors refuse (#6994/#7095).
38+
expect((opportunity as any)?.fields?.expected_revenue?.type).toBe('formula');
39+
});
40+
41+
it('the served projection marks the cell unsortable', () => {
42+
const { fields } = resolveObjectSortability(opportunity);
43+
expect(fields.expected_revenue).toEqual({ sortable: false, reason: 'virtual-type' });
44+
});
45+
46+
it('anti-vacuity: the ordinary persisted columns beside it stay sortable', () => {
47+
const { fields } = resolveObjectSortability(opportunity);
48+
expect(fields.amount).toEqual({ sortable: true });
49+
expect(fields.close_date).toEqual({ sortable: true });
50+
expect(fields.name).toEqual({ sortable: true });
51+
});
52+
});

0 commit comments

Comments
 (0)