diff --git a/.changeset/22200-drafts-header-label.md b/.changeset/22200-drafts-header-label.md new file mode 100644 index 00000000000..8933fb6bf8f --- /dev/null +++ b/.changeset/22200-drafts-header-label.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +--- + +feat(spec,metadata-protocol): each `GET /meta/_drafts` row carries the draft body's own `label`, or `null` + +Clause-②: yes (widening) + +- **What a client can now read.** Every row of the pending-drafts list (`GET /api/v1/meta/_drafts`, the runtime's `GET /metadata/_drafts`, and the SDK's `client.meta.listDrafts()`) carries `label`: the draft body's own top-level `label`. This list is the only place a client finds an item that exists only as a draft, so before this such an item could be shown by its machine name alone (a draft permission set read `technician`, not "Technician"). +- **Its shape.** `I18nLabel | null`, required on the wire. It is carried as authored: a plain string, or an inline locale map (`{ en: …, 'zh-CN': … }`) on the types whose `label` is an `I18nLabel`. The route takes no locale, so the reader resolves a map the way it resolves every other `I18nLabel` (`resolveI18nLabel` in `@objectstack/spec/ui`). +- **When it is `null`.** The body declares no `label`; it declares one `I18nLabelSchema` refuses (a row stored before its type's schema was enforced on save, or a row of a type with no registered schema); or its stored bytes do not parse, in which case the draft stays listed and every read of its body still fails. It is never the item name standing in for a missing label: a reader that wants a fallback chooses it, knowing the label is absent. +- **Where it comes from.** `SysMetadataRepository.listDrafts` reads it off the `sys_metadata` row it already fetches, so there is no second query, and `ObjectStackProtocolImplementation.listDrafts` passes it through. Of the stored body, only this one member leaves; field definitions and every other body key stay off the header. +- **Unchanged.** The other six members, the filters (`?packageId=`, `?type=`), the org scope, and the authoring gate on both routes. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 8e22db53959..8ec5ecee16c 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2087,7 +2087,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **drafts** | `{ type: string; name: string; organizationId: string \| null; packageId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. | +| **drafts** | `{ type: string; name: string; label: string \| Record \| null; organizationId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. | ### Nested Shape: `ListDraftsResponse.drafts[number]` @@ -2095,6 +2095,7 @@ Install package response | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name (canonical singular). | | **name** | `string` | ✅ | Item name. | +| **label** | `string \| Record \| null` | ✅ | The draft body's own top-level `label`, as authored: a plain string or an inline locale map (`I18nLabel`), resolved by the reader. `null` when the body declares none, or none this shape admits — never the item name standing in for it. | | **organizationId** | `string \| null` | ✅ | Owning organization of the draft row, `null` for an environment-wide draft. | | **packageId** | `string \| null` | ✅ | Package the draft is bound to, `null` for a package-less draft. | | **updatedAt** | `string \| null` | ✅ | Last-touch timestamp of the draft row (ISO-8601 string), `null` when the row recorded none. | diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6df9a994bc3..ea126f2e7a5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22831,7 +22831,10 @@ export class ObjectStackProtocolImplementation implements * by `packageId` and/or `type`. The list reads of `getMetaItems` only see * the ACTIVE registry; this exposes what an AI authored but a human hasn't * published yet, so the console can show a "pending changes" surface and a - * just-built app package isn't displayed as empty. No body is returned. + * just-built app package isn't displayed as empty. No body is returned — + * only its own `label` ([#22200]), the one member a draft-only item can be + * shown by other than its machine name, passed through from + * {@link SysMetadataRepository.listDrafts} as the repository projects it. */ async listDrafts(request?: { packageId?: string; @@ -22841,6 +22844,8 @@ export class ObjectStackProtocolImplementation implements drafts: Array<{ type: string; name: string; + /** The draft body's own top-level `label`, as authored; `null` when it declares none. */ + label: I18nLabel | null; organizationId: string | null; packageId: string | null; updatedAt: string | null; @@ -22907,8 +22912,9 @@ export class ObjectStackProtocolImplementation implements * ## Why the batch's existing enumeration cannot supply the bodies * * `listDrafts` — the read that DEFINES this batch — is a declared header - * projection: it maps rows to `(type, name, organizationId, packageId, - * updatedAt, updatedBy)` and drops `metadata` on purpose, because its other + * projection: it maps rows to `(type, name, label, organizationId, + * packageId, updatedAt, updatedBy)` and drops `metadata` on purpose — of the + * body only its own top-level `label` leaves ([#22200]) — because its other * caller is the console's "pending changes" list. Widening it would put * every draft BODY on that listing, and it would not even remove the guard * below: the doubles that lack `repo.get` stub `listDrafts` too, so a diff --git a/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts index 9931fb62d5a..15531c6f8d3 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts @@ -66,7 +66,10 @@ import { assertEngineUpdateDispatch, assertEngineFindOnePredicate, } from '@objectstack/metadata-core'; +import { ListDraftsResponseSchema } from '@objectstack/spec/api'; +import { I18nLabelSchema } from '@objectstack/spec/ui'; import { SysMetadataRepository } from './sys-metadata-repository.js'; +import { ObjectStackProtocolImplementation } from './protocol.js'; interface Row { [k: string]: unknown; @@ -107,6 +110,8 @@ const INVALID_INSTANT = new Date(NaN); const CONFORMS: { [K in keyof DraftHeader]: (value: DraftHeader[K]) => boolean } = { type: (v) => typeof v === 'string', name: (v) => typeof v === 'string', + // [#22200] The draft body's own label: an `I18nLabel` as authored, or `null`. + label: (v) => v === null || I18nLabelSchema.safeParse(v).success, organizationId: (v) => v === null || typeof v === 'string', packageId: (v) => v === null || typeof v === 'string', updatedAt: (v) => v === null || typeof v === 'string', @@ -346,3 +351,165 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever }); }); }); + +/** + * [#22200] The `_drafts` header carries the draft body's own `label`. + * + * `GET /meta/_drafts` is the only list of draft-only items a client has, so a + * header without a label leaves such an item showable by its machine name + * alone — a permission set just created as a draft read `technician`, not + * "Technician". The label is read off the row `listDrafts` already holds (no + * second query), carried as authored, and `null` when the body declares none: + * ⛔ never the item name standing in for it. + * + * Shares this file's engine double rather than pinning a second one: the + * projection under test is the same `listDrafts` map, and the conformance + * table above now covers `label` on every case in the file. + */ +describe('#22200 — each listDrafts row carries the draft body\'s own label, or null', () => { + const HEADER_KEYS = ['label', 'name', 'organizationId', 'packageId', 'type', 'updatedAt', 'updatedBy']; + + describe('§A drafts saved through the real write path', () => { + it('a draft whose body declares a label returns it', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await repo.put( + { org: 'env', type: 'view', name: 'repair_ticket_board' } as never, + { name: 'repair_ticket_board', label: 'Repair Ticket Board' }, + { parentVersion: null, actor: 'usr_1', state: 'draft' } as never, + ); + // Non-vacuity: the writer stored a DRAFT row whose body is serialized + // text, the shape the read below has to parse. + expect(engine.rows).toHaveLength(1); + expect(engine.rows[0]!.state).toBe('draft'); + expect(typeof engine.rows[0]!.metadata).toBe('string'); + + const drafts = await repo.listDrafts(); + expect(drafts).toHaveLength(1); + expect(drafts[0]!.label).toBe('Repair Ticket Board'); + expectConformsToDeclaration(drafts); + }); + + it('a draft whose body declares none reads null — not its machine name', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await repo.put( + { org: 'env', type: 'view', name: 'repair_ticket_board' } as never, + { name: 'repair_ticket_board' }, + { parentVersion: null, actor: 'usr_1', state: 'draft' } as never, + ); + + const drafts = await repo.listDrafts(); + expect(drafts).toHaveLength(1); + expect(drafts[0]!.label).toBeNull(); + expect(drafts[0]!.label).not.toBe(drafts[0]!.name); + expectConformsToDeclaration(drafts); + }); + }); + + describe('§B the label is carried AS AUTHORED', () => { + it('an inline locale map (the `I18nLabel` form) is carried verbatim, not resolved to one locale', async () => { + // The route takes no locale, so resolving here would be the producer + // choosing a language for the reader. + const map = { en: 'Field Service', 'zh-CN': '现场服务' }; + const engine = makeFakeEngine([ + draftRow({ type: 'app', name: 'field_service', metadata: JSON.stringify({ name: 'field_service', label: map }) }), + ]); + const repo = makeRepo(engine); + + const drafts = await repo.listDrafts(); + expect(drafts[0]!.label).toEqual(map); + expectConformsToDeclaration(drafts); + }); + + it('reads a body the driver already materialised as an object (a JSON-column dialect)', async () => { + const engine = makeFakeEngine([draftRow({ metadata: { label: 'Cases' } })]); + const repo = makeRepo(engine); + + expect(typeof engine.rows[0]!.metadata).toBe('object'); + + const drafts = await repo.listDrafts(); + expect(drafts[0]!.label).toBe('Cases'); + expectConformsToDeclaration(drafts); + }); + }); + + describe('§C null is the declared "no label", never an invented one', () => { + it.each([ + ['a number', 42], + ['the retired key-reference form', { key: 'views.case_grid.label', defaultValue: 'Cases' }], + ['an array', ['Cases']], + ])('a stored label the contract cannot carry (%s) reads null, and the draft is still listed', async (_shape, label) => { + const engine = makeFakeEngine([draftRow({ metadata: JSON.stringify({ label }) })]); + const repo = makeRepo(engine); + + // Non-vacuity: the stored body really carries a `label` key. + expect(JSON.parse(engine.rows[0]!.metadata as string)).toHaveProperty('label'); + + const drafts = await repo.listDrafts(); + expect(drafts).toHaveLength(1); + expect(drafts[0]!.label).toBeNull(); + expectConformsToDeclaration(drafts); + }); + + it('stored bytes that do not parse read null rather than failing the whole listing', async () => { + const engine = makeFakeEngine([ + draftRow({ name: 'torn_grid', metadata: '{"label":"Torn' }), + draftRow({ name: 'lead_grid', metadata: '{"label":"Leads"}' }), + ]); + const repo = makeRepo(engine); + + expect(() => JSON.parse(engine.rows[0]!.metadata as string)).toThrow(); + + const drafts = await repo.listDrafts(); + expect(drafts.map((d) => [d.name, d.label]).sort()).toEqual([ + ['lead_grid', 'Leads'], + ['torn_grid', null], + ]); + expectConformsToDeclaration(drafts); + }); + }); + + describe('§D the protocol passes it through, and the spec schema agrees', () => { + it('ObjectStackProtocolImplementation.listDrafts serves the label, and the response parses as ListDraftsResponseSchema, every member preserved', async () => { + const engine = makeFakeEngine([ + draftRow({ type: 'permission', name: 'technician', metadata: JSON.stringify({ name: 'technician', label: 'Technician' }) }), + draftRow({ type: 'object', name: 'repairs_repair_ticket', metadata: JSON.stringify({ name: 'repairs_repair_ticket' }) }), + ]); + const protocol = new ObjectStackProtocolImplementation(engine as never); + + const response = await protocol.listDrafts(); + const byName = Object.fromEntries(response.drafts.map((d) => [d.name, d])); + expect(byName.technician!.label).toBe('Technician'); + expect(byName.repairs_repair_ticket!.label).toBeNull(); + + const parsed = ListDraftsResponseSchema.safeParse(response); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data).toEqual(response); + }); + + it('the label is the ONLY member read off the body — the header keys are exactly seven (#6599)', async () => { + const engine = makeFakeEngine([ + draftRow({ + type: 'object', + name: 'account', + metadata: JSON.stringify({ + name: 'account', + label: 'Account', + description: 'internal pricing notes', + fields: { salary_grade: { type: 'select', label: 'Salary Grade' } }, + }), + }), + ]); + const protocol = new ObjectStackProtocolImplementation(engine as never); + + const response = await protocol.listDrafts(); + expect(Object.keys(response.drafts[0]!).sort()).toEqual(HEADER_KEYS); + const wire = JSON.stringify(response); + for (const secret of ['internal pricing notes', 'salary_grade', 'Salary Grade', 'fields']) { + expect(wire).not.toContain(secret); + } + expect(response.drafts[0]!.label).toBe('Account'); + }); + }); +}); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 690b710cc41..17ab5b7d40b 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -81,6 +81,7 @@ import type { } from '@objectstack/metadata-core'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; +import { I18nLabelSchema, type I18nLabel } from '@objectstack/spec/ui'; import type { IObjectQLEngine } from '@objectstack/core'; // [#7682] The read-only-package predicate, imported rather than re-spelled — // the same function `saveMetaItem`'s ADR-0070 D1 gate and the `/packages` @@ -195,6 +196,41 @@ function storedRowBody(row: any): Record { return typeof row.metadata === 'string' ? JSON.parse(row.metadata) : (row.metadata ?? {}); } +/** + * [#22200] A draft row's own top-level `label`, for the `_drafts` header + * ({@link SysMetadataRepository.listDrafts}) — the ONE member that header + * reads off the stored body. The header stays a disclosure boundary (#6599): + * the label leaves, the body never does. + * + * Carried AS AUTHORED. Every registered metadata type that declares a display + * label spells it top-level `label`, as a plain string or an `I18nLabel` inline + * locale map; the route takes no locale to resolve a map with, so the reader + * resolves it. No ADR-0087 conversion rewrites a top-level `label`, so the + * stored spelling is already the canonical one. + * + * `null` — the declared "no label" — in three cases, and ⛔ never the item + * name, which would make "has a label" and "has none" one answer: + * - the body declares none; + * - it declares one `I18nLabelSchema` refuses (a row stored before its + * type's schema was enforced on save, or a row of a type with no + * registered schema). The declared field cannot carry it, and serving it + * would hand the reader a shape its own type rules out; + * - its stored bytes do not parse. {@link SysMetadataRepository.lockHead} + * answers the same bytes the same way, for the same reason: a header + * listing must not turn into a parse failure. The draft stays listed — and + * so discardable — while every read of its body still fails loudly. + */ +function draftBodyLabel(row: any): I18nLabel | null { + let body: Record | null; + try { + body = storedRowBody(row); + } catch { + return null; + } + const parsed = I18nLabelSchema.safeParse(body?.label); + return parsed.success ? parsed.data : null; +} + /** * Overlay-row lifecycle state. * @@ -1318,6 +1354,12 @@ export class SysMetadataRepository implements MetadataRepository { Array<{ type: string; name: string; + /** + * [#22200] The draft body's own top-level `label`, as authored — `null` + * when it declares none (see {@link draftBodyLabel}). For a draft-only + * item this header is the only place a client can find a label at all. + */ + label: I18nLabel | null; /** * The scope the draft actually lives in — `null` for an env-wide draft, * a string for a per-org overlay draft. The `$or` below surfaces BOTH to @@ -1337,6 +1379,8 @@ export class SysMetadataRepository implements MetadataRepository { return (rows as any[]).map((row) => ({ type: row.type, name: row.name, + // [#22200] Off the row this read already holds — no second query. + label: draftBodyLabel(row), organizationId: row.organization_id ?? null, packageId: row.package_id ?? null, // [commit c383352cb] `updated_at` / `created_at` are the BUILTIN audit columns, diff --git a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts index 73e71f09b20..bfce4199fe6 100644 --- a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts +++ b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts @@ -109,26 +109,33 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { * `GET /api/v1/meta/_drafts` (`packages/rest/src/rest-server.ts`) and * `GET /metadata/_drafts` (`packages/runtime/src/domains/meta.ts`) both call * `protocol.listDrafts()` and serve the result verbatim — no ADR-0106 - * `applyObjectSchemaMask`, no capability gate, nothing beyond `requireAuth`. - * That is safe today for exactly one reason: this function reads six columns - * and never touches the row's stored body. #6599 was filed believing the body - * DID come through (`.item.fields.salary_grade`); it does not, and this case - * is what keeps that true. + * `applyObjectSchemaMask`. Both now GATE per caller (#6599's authoring gate, + * `mayReadPendingDrafts`), but the projection is still what decides what an + * authoring caller receives on a list surface. #6599 was filed believing the + * body DID come through (`.item.fields.salary_grade`); it does not, and this + * case is what keeps that true. + * + * [#22200] Exactly ONE member is read off the stored body: the item's own + * top-level `label`, because this header is the only list of draft-only + * items a client has and without it such an item can be shown by its machine + * name alone. That is a label, not the body — so the pin below names seven + * keys, and the sweep still holds every field-level secret off the wire. * * So the moment anyone "helpfully" widens the projection to carry the item — * a Studio diff view wanting field-level detail is the obvious pull — both * routes start serving full object schemas, `requiredPermissions`, picklist - * option values and `formula` business IP to any authenticated caller, and - * ADR-0106's mask is bypassed wholesale via a route it never covered. This - * test goes red at that instant. If you are here because it went red: the - * widening needs the ADR-0106 projection (or an authoring gate) on BOTH - * routes FIRST — see #6599 for the (a)/(b) fork. + * option values and `formula` business IP, and ADR-0106's mask is bypassed + * wholesale via a route it never covered. This test goes red at that + * instant. If you are here because it went red: a field-level widening needs + * the ADR-0106 projection on BOTH routes FIRST — see #6599 for the (a)/(b) + * fork. */ it('projects headers ONLY — a draft row\'s stored body never reaches the caller (#6599)', async () => { // A draft row carrying everything ADR-0106 names as leaking with a field: // a sensitive picklist, the capability guarding it, and a formula. const sensitive = { name: 'account', + label: 'Account', fields: { salary_grade: { type: 'select', @@ -148,7 +155,10 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { organization_id: null, updated_at: 't1', updated_by: 'ai', - // Both spellings the repository layer has used for the stored document. + // The column the repository reads the stored document from today + // (`metadata`, the one `label` is now read out of) beside both + // spellings it has used before — the sweep has to cover all three. + metadata: JSON.stringify(sensitive), body: JSON.stringify(sensitive), metadata_json: JSON.stringify(sensitive), }, @@ -156,8 +166,9 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { const { repo } = makeRepo(rows as any); const out = await repo.listDrafts({ type: 'object' }); - // Exactly the six header keys — no `item`, no `body`, no `fields`. + // Exactly the seven header keys — no `item`, no `body`, no `fields`. expect(Object.keys(out[0]).sort()).toEqual([ + 'label', 'name', 'organizationId', 'packageId', @@ -181,5 +192,7 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { ]) { expect(wire).not.toContain(secret); } + // [#22200] …while the one member read off the body arrives, as authored. + expect(out[0].label).toBe('Account'); }); }); diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index afd19f2ce9a..f0acadc5a96 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -2573,20 +2573,35 @@ import { } from './protocol.zod'; describe('ListDraftsResponseSchema declares the pending-drafts body', () => { - /** A verbatim-shaped capture of a real `listDrafts` return (one org draft). */ + /** + * A verbatim-shaped capture of a real `listDrafts` return: a plain-string + * label, an inline-locale-map label (the `I18nLabel` form the UI types + * author), and a draft whose body declares no label at all. + */ const realResponse = { drafts: [ { type: 'view', name: 'account_pipeline', + label: 'Account Pipeline', organizationId: 'org_01', packageId: 'com.example.crm', updatedAt: '2026-08-27T09:12:44.000Z', updatedBy: 'admin@objectos.ai', }, + { + type: 'app', + name: 'field_service', + label: { en: 'Field Service', 'zh-CN': '现场服务' }, + organizationId: null, + packageId: 'com.example.fsm', + updatedAt: '2026-08-27T09:13:02.000Z', + updatedBy: 'admin@objectos.ai', + }, { type: 'object', name: 'lead_source', + label: null, organizationId: null, packageId: null, updatedAt: null, @@ -2601,6 +2616,23 @@ describe('ListDraftsResponseSchema declares the pending-drafts body', () => { if (result.success) expect(result.data).toEqual(realResponse); }); + it('a draft whose body declares no label reads `null` — never the item name standing in for it', () => { + const result = ListDraftsResponseSchema.safeParse(realResponse); + expect(result.success).toBe(true); + if (!result.success) return; + const unlabelled = result.data.drafts.find((d) => d.name === 'lead_source'); + expect(unlabelled?.label).toBeNull(); + }); + + it('`label` is required on the wire: a row that omits it is refused at the member', () => { + const { label: _omitted, ...withoutLabel } = realResponse.drafts[2]!; + const result = ListDraftsResponseSchema.safeParse({ drafts: [withoutLabel] }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((i) => i.path.join('.'))).toContain('drafts.0.label'); + } + }); + it('the honest-empty answer parses — {drafts: []} is a declared, legal body', () => { expect(ListDraftsResponseSchema.safeParse({ drafts: [] }).success).toBe(true); }); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 60907a9a4c2..e6069bc3b2e 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { ViewSchema } from '../ui/view.zod'; +import { I18nLabelSchema } from '../ui/i18n.zod'; import { DiscoverySchema } from './discovery.zod'; import { BatchUpdateRequestSchema, @@ -1781,11 +1782,27 @@ export const GetPublishedMetaItemResponseSchema = lazySchema(() => z.unknown().d * * Transcribed from `listDrafts`'s declared return * (`@objectstack/metadata-protocol` `protocol.ts`). + * + * `label` is the one member read off the draft BODY: the only list of + * draft-only items a client has is this one, so without it such an item can be + * shown by its machine name alone. It is the body's own top-level `label`, + * carried as authored. Every registered metadata type that declares a display + * label spells it `label` at the top level, as a plain string or, on the UI + * types, as an `I18nLabel` inline locale map — which the reader resolves the + * way it resolves every other `I18nLabel`, since this route takes no locale. + * `null` is the honest answer for a body that declares none; ⛔ never a + * machine-name fallback, which would make "has a label" and "has none" + * indistinguishable to the reader that has to choose between them. */ export const ListDraftsResponseSchema = lazySchema(() => z.object({ drafts: z.array(z.object({ type: z.string().describe('Metadata type name (canonical singular).'), name: z.string().describe('Item name.'), + label: I18nLabelSchema.nullable().describe( + 'The draft body\'s own top-level `label`, as authored: a plain string or an inline ' + + 'locale map (`I18nLabel`), resolved by the reader. `null` when the body declares ' + + 'none, or none this shape admits — never the item name standing in for it.', + ), organizationId: z.string().nullable().describe( 'Owning organization of the draft row, `null` for an environment-wide draft.', ),