From 0b6a88e4eb259a6e4be3938029710388a574f064 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:01:58 +0000 Subject: [PATCH 1/5] feat(spec): GetMetaItemsRequestSchema declares names, the batch by-names list read An optional list of item names narrows a /meta/:type list to the named items. An unknown name is simply absent; an empty list is refused rather than read as every item. On the wire it is one comma-separated value (?names=a,b,c), split by the REST door. Claude-Session: https://claude.ai/code/session_01WGufzik1dDv4DzrV1fWBV9 Co-Authored-By: Claude --- packages/spec/src/api/protocol.test.ts | 60 ++++++++++++++++++++++++++ packages/spec/src/api/protocol.zod.ts | 29 +++++++++++++ 2 files changed, 89 insertions(+) diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index d51cdc1c963..06b4e58d974 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -1872,6 +1872,66 @@ describe('environmentId stays OUT of the meta-read request shape — transport-l }); }); +// Its own import lines, as the blocks around it: this group pins one member. +import { z } from 'zod'; +import type { GetMetaItemsRequest } from './protocol.zod'; + +describe('GetMetaItemsRequestSchema declares `names` — the batch by-names list read', () => { + // Ruling A on #29236: the list narrows to the named items, an unknown name + // is simply absent, and an empty value is refused rather than read as every + // item. This is the protocol's post-parse shape — a list; the wire's one + // comma-separated value is split by the REST door. As in the blocks above, + // accept-pins assert the parsed VALUE: the object is non-strict, so + // `success` alone is also what an undeclared, stripped key looks like. + + it('accepts names and PRESERVES the list through parse', () => { + const result = GetMetaItemsRequestSchema.safeParse({ type: 'app', names: ['crm', 'helpdesk'] }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.names).toEqual(['crm', 'helpdesk']); + }); + + it('keeps names OPTIONAL — a request without it lists every item, and parse invents none', () => { + const result = GetMetaItemsRequestSchema.safeParse({ type: 'app' }); + expect(result.success).toBe(true); + if (result.success) expect('names' in result.data).toBe(false); + }); + + it('refuses an EMPTY list, at `names` — an empty filter is never read as every item', () => { + const result = GetMetaItemsRequestSchema.safeParse({ type: 'app', names: [] }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0]).toMatchObject({ code: 'too_small', path: ['names'] }); + } + }); + + it('judges no member — a name that matches no item is kept, to be simply absent from the answer', () => { + for (const names of [['no_such_app'], [''], ['crm', 'crm']]) { + const result = GetMetaItemsRequestSchema.safeParse({ type: 'app', names }); + expect(result.success, JSON.stringify(names)).toBe(true); + if (result.success) expect(result.data.names).toEqual(names); + } + }); + + it('refuses the wire spelling — a comma string is the REST door\'s to split, not a protocol value', () => { + expect(GetMetaItemsRequestSchema.safeParse({ type: 'app', names: 'crm,helpdesk' }).success).toBe(false); + expect(GetMetaItemsRequestSchema.safeParse({ type: 'app', names: [1] }).success).toBe(false); + }); + + it('states the minimum in the published JSON Schema (a minimum, not a refinement)', () => { + const json = z.toJSONSchema(GetMetaItemsRequestSchema, { io: 'input', unrepresentable: 'any' }) as { + properties: Record>; + required?: string[]; + }; + expect(json.properties.names).toMatchObject({ type: 'array', minItems: 1, items: { type: 'string' } }); + expect(json.required ?? []).not.toContain('names'); + }); + + it('types names as an optional string list, input and output alike', () => { + expectTypeOf().toEqualTypeOf(); + }); +}); + describe('MetadataProtocol declares getMetaItemLayered', () => { // Type-level pins (compiled by the spec test typecheck, the // translation-typegen.test.ts pattern). The member is an interface diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 744b13f7428..e8475ac8090 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -238,10 +238,39 @@ export const GetMetaTypesResponseSchema = lazySchema(() => z.object({ * must carry it alongside a request do so in a typed transport envelope at * their own layer (see `TransportScopedMetaRequest` in `@objectstack/rest`), * never by widening these shapes. + * + * **`names` is the batch by-names read of one type** (ruling A on #29236, + * record 6580261740): a caller that wants a handful of items asks the list + * once instead of reading the whole type, or reading each item by name. This + * member is the protocol's post-parse shape — a LIST of item names; the wire + * spelling `?names=a,b,c` (one comma-separated value, the data routes' + * convention for `fields` and `searchFields`, which fits the list door's arity + * rule that refuses a repeated key) is split by the REST door, not here. The + * protocol pushes it down to the store read, so it filters before decorating + * and masking and translation run on the named items only. + * + * The list arm refuses only the EMPTY list, as `searchFieldsArray` does + * (`../data/search-fields-names-a-field.ts`): absent means "every item", so an + * empty filter read as absent is a widening — the class the list door's `?id=` + * / `?object=` refusal closes (`refuseEmptyListFilter` in `@objectstack/rest`). + * The schema judges no member: a name that matches no item is not refused, + * it is simply absent from the answer, as an unknown `?id=` narrows to the + * empty list. A minimum, not a refinement, so the published JSON Schema + * states it. */ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name (e.g., "object", "plugin")'), packageId: z.string().optional().describe('Optional package ID to filter items by'), + names: z.array(z.string()) + .min(1, 'Name at least one item, or leave `names` out to list every item') + .optional() + .describe( + 'Item names to narrow the list to: only the named items of the type are answered, among those ' + + 'the list would answer without it. Sent over HTTP as one comma-separated value ' + + '(`?names=a,b,c`). A name that matches no item is simply absent from the answer (no 404). An ' + + 'empty value is refused with 400 rather than read as every item; leave the key out to list ' + + 'every item.', + ), previewDrafts: z.boolean().optional().describe( 'Draft-visibility switch (ADR-0033 draft-overlay preview): when true, ' + 'pending `state=\'draft\'` rows are overlaid on the active list — draft ' From c968b47f780c42277af1bb37076f4b31b61f55e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:07:06 +0000 Subject: [PATCH 2/5] chore(spec): regenerate the reference docs and the api authorable shard for names; changeset Claude-Session: https://claude.ai/code/session_01WGufzik1dDv4DzrV1fWBV9 Co-Authored-By: Claude --- .changeset/29441-meta-items-names.md | 16 ++++++++++++++++ content/docs/references/api/protocol.mdx | 1 + packages/spec/authorable-surface/api.json | 1 + 3 files changed, 18 insertions(+) create mode 100644 .changeset/29441-meta-items-names.md diff --git a/.changeset/29441-meta-items-names.md b/.changeset/29441-meta-items-names.md new file mode 100644 index 00000000000..5aa679aca8c --- /dev/null +++ b/.changeset/29441-meta-items-names.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `GetMetaItemsRequestSchema` declares `names`, the batch by-names read of a `/meta/:type` list + +Clause-②: yes (widening) +Metadata-protocol: added + +A caller that wants a handful of items of one metadata type used to read the whole type, or read each item by name. The list request now declares an optional `names`, a list of item names: + +- **What it answers.** Only the named items of the type, among those the list would answer without it. A name that matches no item is simply absent from the answer; there is no 404. +- **On the wire.** One comma-separated value, `GET /api/v1/meta/:type?names=a,b,c`, the data routes' convention for `fields` and `searchFields`. The request member is the protocol's post-parse shape, a list (`names: ['a', 'b', 'c']`); the REST door splits the value. +- **An empty value is refused.** The schema refuses an empty list (`minItems: 1` in the published JSON Schema), and the door answers an empty `?names=` with 400, rather than reading it as every item. Leave the key out to list every item. + +This entry declares the member only. The REST door, the dispatcher and the store read that serve it are a separate change to `@objectstack/rest`, `@objectstack/runtime` and `@objectstack/metadata-protocol`; until a release carries it, a list request carrying `names` is answered as if it were absent. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 6dc761fcc64..42164a6d91c 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1514,6 +1514,7 @@ One changed key path of the pending draft, and the last write that changed it. | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name (e.g., "object", "plugin") | | **packageId** | `string` | optional | Optional package ID to filter items by | +| **names** | `string[]` | optional | Item names to narrow the list to: only the named items of the type are answered, among those the list would answer without it. Sent over HTTP as one comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the answer (no 404). An empty value is refused with 400 rather than read as every item; leave the key out to list every item. | | **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. | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 03c12f79ac8..6ee448a864b 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -826,6 +826,7 @@ "api/GetMetaItemResponse:sortability", "api/GetMetaItemResponse:type", "api/GetMetaItemResponse:version", + "api/GetMetaItemsRequest:names", "api/GetMetaItemsRequest:packageId", "api/GetMetaItemsRequest:previewDrafts", "api/GetMetaItemsRequest:type", From 064d4e01dc713cb2df6c30f60acf97c0a1cb6866 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:55:39 +0000 Subject: [PATCH 3/5] chore(spec): regenerate the api authorable shard on the merged tree Claude-Session: https://claude.ai/code/session_01WGufzik1dDv4DzrV1fWBV9 Co-Authored-By: Claude --- packages/spec/authorable-surface/api.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 6ee448a864b..0c8871c6c36 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -209,14 +209,23 @@ "api/AuthEndpoint:signUpEmail", "api/AuthEndpoint:verifyEmail", "api/AuthFamilies:admin", + "api/AuthFeaturesConfig:admin", + "api/AuthFeaturesConfig:audiencePosture", "api/AuthFeaturesConfig:cloudManaged", + "api/AuthFeaturesConfig:degradedTenancy", + "api/AuthFeaturesConfig:deviceAuthorization", "api/AuthFeaturesConfig:magicLink [RETIRED]", + "api/AuthFeaturesConfig:multiOrgEnabled", + "api/AuthFeaturesConfig:oidcProvider", "api/AuthFeaturesConfig:organization", "api/AuthFeaturesConfig:passkeys [RETIRED]", "api/AuthFeaturesConfig:phoneNumber", "api/AuthFeaturesConfig:phoneNumberOtp", + "api/AuthFeaturesConfig:privacyUrl", "api/AuthFeaturesConfig:sso", "api/AuthFeaturesConfig:ssoEnforced", + "api/AuthFeaturesConfig:tenancyPosture", + "api/AuthFeaturesConfig:termsUrl", "api/AuthFeaturesConfig:turnstileSiteKey", "api/AuthFeaturesConfig:twoFactor", "api/AuthProviderInfo:enabled", @@ -1303,9 +1312,11 @@ "api/OperatorMapping:operator", "api/OperatorMapping:rest", "api/PackageExportBundle:packages", + "api/PackageExportManifest:dependencies", "api/PackageExportManifest:id", "api/PackageExportManifest:label", "api/PackageExportManifest:name", + "api/PackageExportManifest:namespace", "api/PackageExportManifest:version", "api/PackageExportManifest:withheldMaps", "api/PackageInstallManifest:capabilities [RETIRED]", From 8b17a5992edfe9679979d95c15311e7f33265e4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:06:46 +0000 Subject: [PATCH 4/5] docs(spec): mark GetMetaItemsRequest names as planned until a list door serves it Claude-Session: https://claude.ai/code/session_01WGufzik1dDv4DzrV1fWBV9 Co-Authored-By: Claude --- packages/spec/src/api/protocol.test.ts | 8 ++++++++ packages/spec/src/api/protocol.zod.ts | 16 +++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 06b4e58d974..e265f4a725b 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -1930,6 +1930,14 @@ describe('GetMetaItemsRequestSchema declares `names` — the batch by-names list it('types names as an optional string list, input and output alike', () => { expectTypeOf().toEqualTypeOf(); }); + + it('is declared ahead of its producer: the describe opens with the planned marker, never a tracker id', () => { + // No list door reads `names` yet; the change that serves it drops the + // marker and this pin with it. + const description = GetMetaItemsRequestSchema.shape.names.description ?? ''; + expect(description).toMatch(/^\[PLANNED — not yet served\] /); + expect(description).not.toMatch(/#\d{3,}/); + }); }); describe('MetadataProtocol declares getMetaItemLayered', () => { diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index e8475ac8090..c96f948e174 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -257,6 +257,11 @@ export const GetMetaTypesResponseSchema = lazySchema(() => z.object({ * it is simply absent from the answer, as an unknown `?id=` narrows to the * empty list. A minimum, not a refinement, so the published JSON Schema * states it. + * + * **Declared ahead of its producer.** No list door reads `names` yet, so its + * describe opens `[PLANNED — not yet served]`; the server PR on #29236 drops + * the marker in the change that serves the member (the pin is in + * `protocol.test.ts`). */ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name (e.g., "object", "plugin")'), @@ -265,11 +270,12 @@ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ .min(1, 'Name at least one item, or leave `names` out to list every item') .optional() .describe( - 'Item names to narrow the list to: only the named items of the type are answered, among those ' - + 'the list would answer without it. Sent over HTTP as one comma-separated value ' - + '(`?names=a,b,c`). A name that matches no item is simply absent from the answer (no 404). An ' - + 'empty value is refused with 400 rather than read as every item; leave the key out to list ' - + 'every item.', + '[PLANNED — not yet served] Item names to narrow the list to: only the named items of the type ' + + 'are answered, among those the list would answer without it. Sent over HTTP as one ' + + 'comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the ' + + 'answer (no 404). An empty value is refused with 400 rather than read as every item; leave the ' + + 'key out to list every item. No list door reads it yet: until one does, a request carrying it ' + + 'is answered as if it were absent.', ), previewDrafts: z.boolean().optional().describe( 'Draft-visibility switch (ADR-0033 draft-overlay preview): when true, ' From bb07931cb445ee6b0d4fc308b359dd87f9766d31 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:10:15 +0000 Subject: [PATCH 5/5] chore(spec): regenerate the reference docs for the planned names marker Claude-Session: https://claude.ai/code/session_01WGufzik1dDv4DzrV1fWBV9 Co-Authored-By: Claude --- content/docs/references/api/protocol.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 42164a6d91c..638e4013d63 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1514,7 +1514,7 @@ One changed key path of the pending draft, and the last write that changed it. | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name (e.g., "object", "plugin") | | **packageId** | `string` | optional | Optional package ID to filter items by | -| **names** | `string[]` | optional | Item names to narrow the list to: only the named items of the type are answered, among those the list would answer without it. Sent over HTTP as one comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the answer (no 404). An empty value is refused with 400 rather than read as every item; leave the key out to list every item. | +| **names** | `string[]` | optional | [PLANNED — not yet served] Item names to narrow the list to: only the named items of the type are answered, among those the list would answer without it. Sent over HTTP as one comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the answer (no 404). An empty value is refused with 400 rather than read as every item; leave the key out to list every item. No list door reads it yet: until one does, a request carrying it is answered as if it were absent. | | **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. |