Skip to content

Commit 2a29caa

Browse files
os-elonclaude
andauthored
feat(spec): declare previewDrafts/state on meta-read requests; record environmentId as transport-level; retire REST door casts (#9741) (#9804)
* feat(spec): declare previewDrafts/state draft-visibility switches on meta-read requests; record environmentId as transport-level (#9741) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw * chore: regen protocol docs; changeset (#9741) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 06f9848 commit 2a29caa

6 files changed

Lines changed: 231 additions & 9 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": patch
4+
---
5+
6+
Declare the draft-visibility switches on the meta-read request schemas, exactly where the implementation enforces them (#9741, maintainer ruling 2026-08-18): `GetMetaItemsRequestSchema` gains `previewDrafts?: boolean`, and `GetMetaItemRequestSchema` gains `state?: 'active' | 'draft'` plus `previewDrafts?: boolean`. Both members are draft-visibility switches only — declaration ≠ authorization: ADR-0106 masking is unaffected, and draft access stays admin-gated upstream. The cached and layered read requests deliberately declare neither (their implementations enforce neither). `environmentId` stays OUT of the protocol request shape by explicit ruling — it is the transport-level multi-kernel routing key, recorded schema-side as a decision rather than an omission. The REST meta-read doors (list, cached and uncached single-item, layered) drop their `as any` request casts: each request literal now compiles against the declared spec shape, with the transport-level `environmentId` carried by a typed transport envelope (`TransportScopedMetaRequest`) instead of a cast. Accept-set widening catch-up on the declared surface; zero runtime behaviour change.

content/docs/references/api/protocol.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,8 @@ Enable package response
753753
| **name** | `string` || Item name (snake_case identifier) |
754754
| **packageId** | `string` | optional | Optional package ID to filter items by |
755755
| **organizationId** | `string` | optional | Organization (tenant) scope for the read. Selects the org partition in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization row is served as the item. Absent = environment-wide read: only env-level overlays apply and no org partition is consulted. |
756+
| **state** | `Enum<'active' \| 'draft'>` | optional | Draft-visibility switch — which lifecycle row to read (strict mode): `'draft'` opens the pending draft buffer (Studio's editor read) and fails when no draft exists; absent or `'active'` reads the live published row. Distinct from `previewDrafts`, which FALLS BACK to the active row when no draft exists. Declaration ≠ authorization: this member only selects which stored row is read — ADR-0106 masking is unaffected, and draft access is gated upstream, not by this schema. |
757+
| **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview, non-strict): when true and `state` is not `'draft'`, a pending draft row is preferred if one exists, else the read falls back to the active row — the render path degrades to the published value instead of erroring. A served draft is tagged `_draft: true` so UIs can badge it. Declaration ≠ authorization: this member only switches which row is read, and ADR-0106 masking is unaffected — draft preview is admin-gated upstream, not by this schema. |
756758

757759

758760
---
@@ -789,6 +791,7 @@ Enable package response
789791
| **type** | `string` || Metadata type name (e.g., "object", "plugin") |
790792
| **packageId** | `string` | optional | Optional package ID to filter items by |
791793
| **organizationId** | `string` | optional | Organization (tenant) scope for the read. Selects the org partition in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization rows are merged into the list. Absent = environment-wide read: only env-level overlays apply and no org partition is consulted. |
794+
| **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview): when true, pending `state='draft'` rows are overlaid on the active list — draft wins on name collision, draft-only items appear, and each overlaid item is tagged `_draft: true` so UIs can badge the preview. Absent/false = published world only. Declaration ≠ authorization: this member only switches which rows are read, and ADR-0106 masking is unaffected — callers without draft-preview authorization are refused upstream (admin-gated), not by this schema. |
792795

793796

794797
---

packages/rest/src/rest-server.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ import { refuseUnknownQueryParams } from './query-allowlist.js';
6060
import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js';
6161
import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api';
6262
import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api';
63+
// [#9741] Declared request shapes for the meta-read doors below — imported so
64+
// each door's request literal is compiled against the spec contract instead of
65+
// being smuggled past it with `as any` (see `TransportScopedMetaRequest`).
66+
import type {
67+
GetMetaItemsRequest,
68+
GetMetaItemRequest,
69+
GetMetaItemCachedRequest,
70+
GetMetaItemLayeredRequest,
71+
} from '@objectstack/spec/api';
6372
// [#8073] The closed ADR-0112 error vocabulary, so the explain family's single
6473
// refusal emitter types its `code` parameter as the vocabulary rather than as
6574
// `string` — an invented code is a compile error at the call site instead of a
@@ -108,6 +117,25 @@ import { sendError as sendEnvelopeError } from '@objectstack/types';
108117
* widen this contract.
109118
*/
110119
export type RestProtocol = DataProtocol & MetadataProtocol;
120+
121+
/**
122+
* [#9741] Typed TRANSPORT envelope for the meta-read doors.
123+
*
124+
* `environmentId` is the multi-kernel routing key, and it is OUT of the
125+
* protocol request shape **by explicit maintainer decision** (ruling recorded
126+
* 2026-08-18 on #9741): `resolveProtocol(environmentId)` selects the target
127+
* kernel *before* the protocol call, and the implementation's parameter types
128+
* (`@objectstack/metadata-protocol`) never read it off the request — the spec
129+
* schemas (`protocol.zod.ts`) record the same exclusion schema-side. The doors
130+
* here still spread it into the outgoing payload (long-standing wire shape,
131+
* deliberately unchanged by the ruling), so this alias declares that one
132+
* transport-level member on top of the declared request type. The point is
133+
* what it makes the compiler do: every OTHER key in a door's request literal
134+
* is now checked against the spec contract — an undeclared member is a compile
135+
* error at the call site, not a cast-and-hope. Never add protocol members
136+
* here; a key that belongs to the request belongs in the spec schema.
137+
*/
138+
type TransportScopedMetaRequest<R> = R & { environmentId?: string };
111139
import {
112140
buildFieldMetaMap,
113141
referenceFieldNames,
@@ -2566,13 +2594,19 @@ export class RestServer {
25662594
const layeredOrganizationId = organizationIdForMetaRead(
25672595
req.params.type, layeredCtx?.tenantId,
25682596
);
2569-
const layered = await p.getMetaItemLayered({
2597+
// [#9741] This door never carried an `as any`, but `p: any` meant its
2598+
// request literal was never checked either — the same blind spot with
2599+
// a different spelling. Typing the literal (spec shape + the
2600+
// transport-level `environmentId`, see `TransportScopedMetaRequest`)
2601+
// makes an undeclared key a compile error here too.
2602+
const layeredRequest: TransportScopedMetaRequest<GetMetaItemLayeredRequest> = {
25702603
type: req.params.type,
25712604
name: req.params.name,
25722605
...(layeredPackageId ? { packageId: layeredPackageId } : {}),
25732606
...(environmentId ? { environmentId } : {}),
25742607
...(layeredOrganizationId ? { organizationId: layeredOrganizationId } : {}),
2575-
});
2608+
};
2609+
const layered = await p.getMetaItemLayered(layeredRequest);
25762610
// [ADR-0106 D5(4)] The layered view is a schema-bearing exit —
25772611
// `code`, `overlay` and `effective` are each a full object schema.
25782612
// Both entry points (the canonical `/layers` path and the deprecated
@@ -3974,13 +4008,18 @@ export class RestServer {
39744008
const listOrganizationId = organizationIdForMetaRead(
39754009
req.params.type, listCtx?.tenantId,
39764010
);
3977-
const items = await p.getMetaItems({
4011+
// [#9741] Typed against the spec request shape plus the
4012+
// transport-level `environmentId` — the `as any` this
4013+
// literal used to carry is retired now that the spec
4014+
// declares `previewDrafts` (and `organizationId`, #9726).
4015+
const listRequest: TransportScopedMetaRequest<GetMetaItemsRequest> = {
39784016
type: req.params.type,
39794017
packageId,
39804018
...(previewDrafts ? { previewDrafts: true } : {}),
39814019
...(environmentId ? { environmentId } : {}),
39824020
...(listOrganizationId ? { organizationId: listOrganizationId } : {}),
3983-
} as any);
4021+
};
4022+
const items = await p.getMetaItems(listRequest);
39844023

39854024
// RBAC-filter app metadata for authenticated users so
39864025
// privileged apps (Studio, Setup, etc.) and gated nav
@@ -4826,7 +4865,14 @@ export class RestServer {
48264865
const cacheI18n = await this.resolveI18nService(environmentId, req);
48274866
const cacheLocale = this.extractLocale(req, cacheI18n);
48284867

4829-
const result = await p.getMetaItemCached({
4868+
// [#9741] Typed request — `as any` retired. The
4869+
// cached read carries NO draft-visibility members
4870+
// on purpose: this branch is unreachable when
4871+
// `previewDrafts` / `?state=draft` are set (the
4872+
// fork above bypasses the cache for both), and the
4873+
// implementation's `getMetaItemCached` signature
4874+
// declares neither.
4875+
const cachedRequest: TransportScopedMetaRequest<GetMetaItemCachedRequest> = {
48304876
type: req.params.type,
48314877
name: req.params.name,
48324878
cacheRequest,
@@ -4840,7 +4886,8 @@ export class RestServer {
48404886
// enters the ETag there, so the validator states
48414887
// the scope rather than inheriting it.
48424888
...(readOrganizationId ? { organizationId: readOrganizationId } : {}),
4843-
} as any);
4889+
};
4890+
const result = await p.getMetaItemCached(cachedRequest);
48444891

48454892
if (result.notModified) {
48464893
res.status(304).send();
@@ -4944,18 +4991,26 @@ export class RestServer {
49444991
const stateParam = typeof req.query?.state === 'string'
49454992
? req.query.state.toLowerCase()
49464993
: undefined;
4947-
const envelope = await p.getMetaItem({
4994+
// [#9741] Typed against the spec request shape —
4995+
// the `as any` this literal used to carry is
4996+
// retired now that the spec declares `state` and
4997+
// `previewDrafts` (and `organizationId`, #9726).
4998+
// No transport envelope: this door does not thread
4999+
// `environmentId` (the kernel was already resolved
5000+
// above), so the plain declared shape suffices.
5001+
const itemRequest: GetMetaItemRequest = {
49485002
type: req.params.type,
49495003
name: req.params.name,
49505004
packageId,
4951-
...(stateParam === 'draft' ? { state: 'draft' } : {}),
5005+
...(stateParam === 'draft' ? { state: 'draft' as const } : {}),
49525006
...(previewDrafts ? { previewDrafts: true } : {}),
49535007
// [#9454] The uncached arm — `dashboard`'s route
49545008
// (`isDashboardType`), and every read the cache
49555009
// exclusions divert here. Same hoisted scope as
49565010
// the cached arm above, by construction.
49575011
...(readOrganizationId ? { organizationId: readOrganizationId } : {}),
4958-
} as any) as Record<string, any>;
5012+
};
5013+
const envelope = await p.getMetaItem(itemRequest) as Record<string, any>;
49595014

49605015
// [#5563] `getMetaItem` answers the envelope
49615016
// `{ type, name, item, lock, … }`. Unwrap ONCE here;

packages/spec/authorable-surface/api.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,8 @@
739739
"api/GetMetaItemRequest:name",
740740
"api/GetMetaItemRequest:organizationId",
741741
"api/GetMetaItemRequest:packageId",
742+
"api/GetMetaItemRequest:previewDrafts",
743+
"api/GetMetaItemRequest:state",
742744
"api/GetMetaItemRequest:type",
743745
"api/GetMetaItemResponse:deletable",
744746
"api/GetMetaItemResponse:editable",
@@ -755,6 +757,7 @@
755757
"api/GetMetaItemResponse:type",
756758
"api/GetMetaItemsRequest:organizationId",
757759
"api/GetMetaItemsRequest:packageId",
760+
"api/GetMetaItemsRequest:previewDrafts",
758761
"api/GetMetaItemsRequest:type",
759762
"api/GetMetaItemsResponse:items",
760763
"api/GetMetaItemsResponse:type",

packages/spec/src/api/protocol.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,121 @@ describe('meta-read request schemas declare organizationId (#9726 — declared =
13571357
});
13581358
});
13591359

1360+
describe('meta-read request schemas declare the draft-visibility switches (#9741 — declared = enforced)', () => {
1361+
// Maintainer ruling 2026-08-18 (#9741): declare `previewDrafts` / `state`
1362+
// exactly where the implementation enforces them, and record `environmentId`
1363+
// as transport-level — OUT of the request shape by decision. The
1364+
// implementation's inline parameter types are the measure:
1365+
// getMetaItems: { …, previewDrafts?: boolean } — no `state`
1366+
// getMetaItem: { …, state?: 'active'|'draft', previewDrafts?: boolean }
1367+
// getMetaItemCached / getMetaItemLayered: NEITHER member
1368+
// As in the #9726 block above, accept-pins assert the parsed VALUE: these are
1369+
// non-strict objects, so `success` alone is exactly the silent-strip state
1370+
// this card closes.
1371+
const previewCases = [
1372+
['GetMetaItemsRequestSchema', GetMetaItemsRequestSchema, { type: 'object' }],
1373+
['GetMetaItemRequestSchema', GetMetaItemRequestSchema, { type: 'view', name: 'account_list' }],
1374+
] as const;
1375+
1376+
it.each(previewCases)('%s accepts previewDrafts and PRESERVES it through parse', (_n, schema, base) => {
1377+
const result = schema.safeParse({ ...base, previewDrafts: true });
1378+
expect(result.success).toBe(true);
1379+
if (result.success) {
1380+
expect((result.data as { previewDrafts?: boolean }).previewDrafts).toBe(true);
1381+
}
1382+
});
1383+
1384+
it.each(previewCases)('%s keeps previewDrafts OPTIONAL — a published-world read stays valid', (_n, schema, base) => {
1385+
const result = schema.safeParse(base);
1386+
expect(result.success).toBe(true);
1387+
if (result.success) {
1388+
expect('previewDrafts' in (result.data as object)).toBe(false);
1389+
}
1390+
});
1391+
1392+
it.each(previewCases)('%s rejects a non-boolean previewDrafts — a switch, not a bag', (_n, schema, base) => {
1393+
expect(schema.safeParse({ ...base, previewDrafts: 'true' }).success).toBe(false);
1394+
expect(schema.safeParse({ ...base, previewDrafts: 1 }).success).toBe(false);
1395+
});
1396+
1397+
it('GetMetaItemRequestSchema accepts state and PRESERVES it through parse', () => {
1398+
for (const state of ['active', 'draft'] as const) {
1399+
const result = GetMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list', state });
1400+
expect(result.success).toBe(true);
1401+
if (result.success) {
1402+
expect((result.data as { state?: string }).state).toBe(state);
1403+
}
1404+
}
1405+
});
1406+
1407+
it('GetMetaItemRequestSchema keeps state OPTIONAL and refuses values outside the vocabulary', () => {
1408+
const absent = GetMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list' });
1409+
expect(absent.success).toBe(true);
1410+
if (absent.success) {
1411+
expect('state' in (absent.data as object)).toBe(false);
1412+
}
1413+
// The lifecycle vocabulary is CLOSED: `archived` is not a read state.
1414+
expect(GetMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list', state: 'archived' }).success).toBe(false);
1415+
expect(GetMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list', state: true }).success).toBe(false);
1416+
});
1417+
1418+
it('getMetaItems declares NO state — the list verb has no strict-draft mode (mirror of the inline type)', () => {
1419+
// Non-strict schema: an undeclared key parses green but is STRIPPED.
1420+
// Stripping is the observable that the member is NOT declared.
1421+
const result = GetMetaItemsRequestSchema.safeParse({ type: 'object', state: 'draft' });
1422+
expect(result.success).toBe(true);
1423+
if (result.success) {
1424+
expect('state' in (result.data as object)).toBe(false);
1425+
}
1426+
});
1427+
1428+
it('cached and layered reads declare NEITHER switch — declared only where enforced', () => {
1429+
const cases = [
1430+
[GetMetaItemCachedRequestSchema, { type: 'view', name: 'account_list' }],
1431+
[GetMetaItemLayeredRequestSchema, { type: 'view', name: 'account_list' }],
1432+
] as const;
1433+
for (const [schema, base] of cases) {
1434+
const result = schema.safeParse({ ...base, previewDrafts: true, state: 'draft' });
1435+
expect(result.success).toBe(true);
1436+
if (result.success) {
1437+
expect('previewDrafts' in (result.data as object)).toBe(false);
1438+
expect('state' in (result.data as object)).toBe(false);
1439+
}
1440+
}
1441+
});
1442+
});
1443+
1444+
describe('environmentId stays OUT of the meta-read request shape — by decision, not omission (#9741)', () => {
1445+
// Maintainer ruling 2026-08-18 (#9741): `environmentId` is the
1446+
// TRANSPORT-level multi-kernel routing key. The REST layer resolves the
1447+
// target kernel from it BEFORE the protocol call, the implementation's
1448+
// parameter types never read it off the request, and these schemas record
1449+
// the same exclusion. This pin is the regression guard for that decision:
1450+
// if someone declares the member, the parse below stops stripping it and
1451+
// this test names the ruling they are overturning.
1452+
const cases = [
1453+
['GetMetaItemsRequestSchema', GetMetaItemsRequestSchema, { type: 'object' }],
1454+
['GetMetaItemRequestSchema', GetMetaItemRequestSchema, { type: 'view', name: 'account_list' }],
1455+
['GetMetaItemCachedRequestSchema', GetMetaItemCachedRequestSchema, { type: 'view', name: 'account_list' }],
1456+
['GetMetaItemLayeredRequestSchema', GetMetaItemLayeredRequestSchema, { type: 'view', name: 'account_list' }],
1457+
] as const;
1458+
1459+
it.each(cases)('%s does not declare environmentId — a carried value is stripped by parse', (_n, schema, base) => {
1460+
const result = schema.safeParse({ ...base, environmentId: 'env_alpha' });
1461+
expect(result.success).toBe(true);
1462+
if (result.success) {
1463+
expect('environmentId' in (result.data as object)).toBe(false);
1464+
}
1465+
});
1466+
1467+
it.each(cases)('%s has no environmentId in its declared shape', (_n, schema) => {
1468+
// Shape-level twin of the strip-pin above: `.shape` enumerates exactly the
1469+
// DECLARED members, so this fails even if stripping semantics ever change.
1470+
const shape = (schema as unknown as { shape: Record<string, unknown> }).shape;
1471+
expect(Object.keys(shape)).not.toContain('environmentId');
1472+
});
1473+
});
1474+
13601475
describe('MetadataProtocol declares getMetaItemLayered (#9740)', () => {
13611476
// Type-level pins (compiled by the spec test typecheck, the
13621477
// translation-typegen.test.ts pattern). The member is an interface

0 commit comments

Comments
 (0)