diff --git a/.changeset/metadata-client-get-unwraps-envelope-4271.md b/.changeset/metadata-client-get-unwraps-envelope-4271.md new file mode 100644 index 000000000..00569cfdf --- /dev/null +++ b/.changeset/metadata-client-get-unwraps-envelope-4271.md @@ -0,0 +1,17 @@ +--- +'@object-ui/data-objectstack': minor +--- + +`MetadataClient.get()` returns the item body its docblock always promised — the field half of the permission matrix is alive again + +`GET /api/v1/meta/:type/:name` answers the spec-declared envelope `{ type, name, item, …protection fields }` — one shape, for published and draft reads alike, since objectstack#5563 collapsed the read to it. `get()` handed that envelope straight back to callers while its own docblock declared it returned "the unwrapped item content". Every consumer reading `obj.fields` therefore read `undefined`. + +The visible cost was the entire field-level half of the permission matrix: expanding any object in `/_console/apps/:app/metadata/permission/:set` reported "No fields registered for this object." with zero checkboxes, for every object, while the network showed that object's 21 fields arriving 200 OK. Reproduced against two objects on fresh loads, and proven not to be the read-only gate — a run with the editor fully writable (864 enabled checkboxes) still showed an empty field sub-table, which is exactly what a read resolving `undefined` predicts. + +That was one symptom of nine. A census of every `get()` call site found **zero** deliberate readers of the envelope and nine consumers reading the body directly, all of them broken the same way: the RLS CEL editor's field lint and autocomplete resolved an empty field set; the dataset inspectors and the preview field/catalog hooks came back empty; the report drill-down's fallback path read `def.object` off the envelope, found nothing and silently returned; the record-page seed synthesized a default layout from an envelope instead of an object; and the Field Designer read `raw.fields` for display and then wrote `{ ...raw, fields }` back — saving the envelope over the object body. None of it was caught, because the test doubles across the repo were written against the docblock: they answered a bare `{ fields }` body, so the suite exercised the documented contract while production ran the other one. + +The fix is at the producer, not the nine consumers. `get()` now unwraps the envelope once, at the client boundary — so every one of those call sites is repaired without being touched. Detection is by the PRESENCE of the three keys `GetMetaItemResponseSchema` declares (`type: string`, `name: string`, an `item` slot), never guessed from payload contents: a metadata document carrying its own `type` and `name` (a view is `{ name, type: 'grid', … }`) has no `item` and is left whole, and a document with an `item` property of its own but no envelope identity is likewise untouched. Key count is deliberately not part of the test, since a real envelope also spreads the ADR-0008 protection carriers. Anything that is not the envelope — an older server answering the bare document — passes through byte-for-byte, and 404 still reads as `null`. + +`getDraft()` is unchanged and keeps returning the envelope, which its docblock declares and roughly eleven call sites depend on by reading `.item`. That asymmetry is now real rather than aspirational: the two methods share one private transport, and differ only in whether they unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft` (this package) remain the shared helpers for taking a draft body out, and both were already tolerant of either shape, so the two seams that reach a draft through `get()` keep their exact semantics — including reading an empty draft as "nothing pending". + +Minor rather than patch: this moves published behavior for existing callers, the same grading `find()`'s resolve-to-reject change took. No signature changed — the `.d.ts` diff is documentation plus one private member — so nothing needs a code edit to keep compiling; a caller that had written its own `.item` compensator against the old behavior would need to drop it, and none exists in this repo. diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldEnvelope.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldEnvelope.test.tsx new file mode 100644 index 000000000..059af338d --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldEnvelope.test.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#4271 — the field half of the permission matrix, driven through a + * REAL `MetadataClient` answering the REAL server shape. + * + * Every other test in this family hands the editor a hand-rolled client whose + * `get()` returns a bare `{ fields }` body — i.e. the shape the client's + * DOCBLOCK promises. Production answers `GET /meta/:type/:name` with the + * spec-declared envelope `{ type, name, item }` (objectstack#5563), so the + * doubles agreed with the documentation while the real client disagreed with + * both, and the whole suite stayed green while the field sub-table was dead for + * every object on every screen. + * + * These cases close that gap: `useMetadataClient` is mocked to hand back a + * genuine `MetadataClient` wired to a fetch that answers exactly what the + * framework answers. Nothing here mocks `get()` itself. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +/** The card's live object: `showcase_project`, 21 fields, served under `item`. */ +const FIELD_NAMES = [ + 'amount', 'budget', 'category', 'closed_at', 'code', 'created_at', 'description', + 'due_date', 'email', 'is_active', 'name', 'notes', 'owner_id', 'phone', 'priority', + 'region', 'revenue', 'stage', 'status', 'tags', 'updated_at', +]; + +const OBJECT_ITEM = { + name: 'showcase_project', + label: 'Project', + fields: Object.fromEntries(FIELD_NAMES.map((n) => [n, { type: 'text', label: n }])), +}; + +/** Exactly what `GET /api/v1/meta/object/showcase_project` returns (200). */ +const OBJECT_ENVELOPE = { type: 'object', name: 'showcase_project', item: OBJECT_ITEM }; + +let capturedFacetProps: Record | null = null; + +vi.mock('./PermissionAdvancedFacets', () => ({ + PermissionAdvancedFacets: (props: Record) => { + capturedFacetProps = props; + return null; + }, +})); + +let clientImpl: MetadataClient; + +vi.mock('./useMetadata', () => ({ + useMetadataClient: () => clientImpl, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }], + }), +})); + +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; + +afterEach(() => { + cleanup(); + capturedFacetProps = null; +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +/** A real MetadataClient over a fetch that speaks the framework's shapes. */ +function realClient(): MetadataClient { + return new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/meta/permission/sales_perms?layers=true')) { + return json({ + code: null, + overlay: null, + overlayScope: null, + effective: { + name: 'sales_perms', + label: 'Sales', + objects: { showcase_project: { allowRead: true } }, + fields: {}, + }, + }); + } + // The single-item read — the ONE shape #5563 left standing. + if (/\/meta\/object\/showcase_project(\?|$)/.test(url)) return json(OBJECT_ENVELOPE); + if (/\/meta\/object(\?|$)/.test(url)) { + return json({ items: [{ name: 'showcase_project', label: 'Project' }] }); + } + return json({ items: [] }); + }) as unknown as typeof fetch, + }); +} + +async function renderExpanded() { + clientImpl = realClient(); + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: /showcase_project/ })); +} + +describe('objectui#4271 · the field sub-table survives the real server envelope', () => { + it('B1: lists the object’s fields with a readable/editable pair each', async () => { + await renderExpanded(); + + // The card's headline symptom, gone. + expect(await screen.findByLabelText('showcase_project.email readable')).toBeInTheDocument(); + expect(screen.queryByText('No fields registered for this object.')).toBeNull(); + + // All 21 fields, both checkboxes each — the 42 the matrix owed this object. + for (const f of FIELD_NAMES) { + expect(screen.getByLabelText(`showcase_project.${f} readable`)).toBeInTheDocument(); + expect(screen.getByLabelText(`showcase_project.${f} editable`)).toBeInTheDocument(); + } + + // 21 > 6, so the field filter is offered — the affordance the QA run + // (objectstack#7695) could not exercise because no row ever rendered. + expect(screen.getByLabelText('Filter fields…')).toBeInTheDocument(); + }); + + it('B1b: the checkbox pair is live, not decorative', async () => { + await renderExpanded(); + + // Default field posture is readable-but-not-editable, so `editable` is the + // half with somewhere to travel. + const editable = await screen.findByLabelText('showcase_project.stage editable'); + expect(editable).not.toBeChecked(); + expect(screen.getByLabelText('showcase_project.stage readable')).toBeChecked(); + + fireEvent.click(editable); + expect(screen.getByLabelText('showcase_project.stage editable')).toBeChecked(); + }); + + it('B2: loadObjectFields feeds the RLS CEL editor real field names', async () => { + // The collateral the card names: CEL lint + autocomplete resolve their + // field set through the same repaired read. + await renderExpanded(); + expect(capturedFacetProps?.loadObjectFields).toBeTypeOf('function'); + + const names = await capturedFacetProps!.loadObjectFields('showcase_project'); + expect(names).toEqual([...FIELD_NAMES].sort((a, b) => a.localeCompare(b))); + }); +}); diff --git a/packages/app-shell/src/views/runtime-metadata-persistence.test.ts b/packages/app-shell/src/views/runtime-metadata-persistence.test.ts index 6262e0bed..0ab3717b3 100644 --- a/packages/app-shell/src/views/runtime-metadata-persistence.test.ts +++ b/packages/app-shell/src/views/runtime-metadata-persistence.test.ts @@ -266,4 +266,33 @@ describe('runtime-metadata-persistence seam (ADR-0034)', () => { expect(unwrapDraftBody({})).toBeNull(); }); }); + + /** + * objectui#4271 — `readRuntimeDraft` reaches the server through + * `MetadataClient.get()`, which now unwraps the `{type,name,item}` envelope + * at the client boundary. So the value this seam actually receives has + * changed shape even though its own code did not. These pin it at the NEW + * read; the envelope cases above stay because `unwrapDraftBody` is still the + * shared helper for `getDraft()`, which does hand back the envelope. + */ + describe('readRuntimeDraft against the unwrapped get() contract (#4271)', () => { + it('resolves the draft body when get() has already unwrapped it', async () => { + const metadataClient = makeMetadataClient(); + metadataClient.get.mockResolvedValue({ regions: [{ name: 'x' }] }); + const draft = await readRuntimeDraft('page', 'invoice_record', { metadataClient }); + expect(metadataClient.get).toHaveBeenCalledWith('page', 'invoice_record', { state: 'draft' }); + expect(draft).toEqual({ regions: [{ name: 'x' }] }); + }); + + it('still reads "nothing pending" as null across both shapes', async () => { + const metadataClient = makeMetadataClient(); + // 404 → get() answers null. + metadataClient.get.mockResolvedValue(null); + expect(await readRuntimeDraft('page', 'p', { metadataClient })).toBeNull(); + // An empty draft body must not read as a pending draft — this is what + // gates the "unpublished changes" indicator. + metadataClient.get.mockResolvedValue({}); + expect(await readRuntimeDraft('page', 'p', { metadataClient })).toBeNull(); + }); + }); }); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 9ded1e4fc..45eaee6b1 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -1031,12 +1031,16 @@ export function viewItemObjectName(item: any): string | undefined { * Unwrap a `?state=draft` view read into its bare body, or `null` when there * is nothing pending (#4139). * - * The framework answers draft reads in a `{type, name, item}` envelope while a - * published read is the bare body — an asymmetry `MetadataClient.getDraft` - * documents and deliberately preserves. An empty body is normalized to `null` - * so the caller's "is this view draft-backed?" test is a plain truthiness - * check. Mirrors app-shell's `unwrapDraftBody` (ADR-0034 seam); the two live - * apart because the seam sits above this adapter, not beside it. + * The framework answers EVERY single-item read — draft or published — in a + * `{type, name, item}` envelope. `MetadataClient.get()` unwraps it at the + * client boundary (objectui#4271) while `getDraft()` deliberately hands the + * envelope back, so this helper stays tolerant of both: the call below reaches + * it through `get()` and therefore already holds the body, and the passthrough + * limb keeps it correct for an envelope arriving by any other route. An empty + * body is normalized to `null` so the caller's "is this view draft-backed?" + * test is a plain truthiness check. Mirrors app-shell's `unwrapDraftBody` + * (ADR-0034 seam); the two live apart because the seam sits above this + * adapter, not beside it. */ function unwrapViewDraft(resp: unknown): Record | null { if (!resp || typeof resp !== 'object') return null; diff --git a/packages/data-objectstack/src/metadata-client.get-envelope.test.ts b/packages/data-objectstack/src/metadata-client.get-envelope.test.ts new file mode 100644 index 000000000..f51d9d803 --- /dev/null +++ b/packages/data-objectstack/src/metadata-client.get-envelope.test.ts @@ -0,0 +1,171 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4271 — `MetadataClient.get()` honors its documented unwrapped-body + * contract. + * + * The framework answers `GET /meta/:type/:name` with the spec-declared + * envelope `{ type, name, item, ...protection fields }` + * (`GetMetaItemResponseSchema`; objectstack#5563 collapsed the read to that ONE + * shape). `get()` used to hand that envelope straight back while its own + * docblock promised the unwrapped body — so every consumer reading `obj.fields` + * saw `undefined`, which is how the whole field half of the permission matrix + * went dead for every object. + * + * The asymmetry that IS real and stays: `getDraft()` documents the envelope and + * its callers read `.item`, so it must keep returning what the server sent. + * A11/A10 below are the pair that pins both halves. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { MetadataClient, type MetadataError } from './metadata-client'; + +function mockFetch(handler: (url: string, init?: RequestInit) => Promise): typeof fetch { + return vi.fn(handler) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +/** A client whose single-item read answers `body`, recording the URLs it hit. */ +function clientAnswering(body: unknown, init?: ResponseInit) { + const urls: string[] = []; + const c = new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: mockFetch(async (url) => { + urls.push(url); + return init ? new Response(JSON.stringify(body), init) : jsonResponse(body); + }), + }); + return { c, urls }; +} + +/** The card's live shape: 21 fields under `item`, as showcase_project answers. */ +const OBJECT_BODY = { + name: 'showcase_project', + label: 'Project', + fields: { name: { type: 'text' }, stage: { type: 'select' } }, +}; +const ENVELOPE = { type: 'object', name: 'showcase_project', item: OBJECT_BODY }; + +describe('objectui#4271 · MetadataClient.get() unwraps the spec envelope', () => { + it('A1: unwraps `{type,name,item}` to the item body', async () => { + const { c } = clientAnswering(ENVELOPE); + const obj = await c.get>('object', 'showcase_project'); + expect(obj).toEqual(OBJECT_BODY); + // The headline consequence: the field map is reachable again. + expect(Object.keys((obj as any)?.fields ?? {})).toEqual(['name', 'stage']); + }); + + it('A2: unwraps when the ADR-0008 protection carriers ride along', async () => { + // `GetMetaItemResponseSchema` spreads MetadataProtectionEnvelopeFields, so + // a real envelope carries more than three keys. Unwrapping must not be + // conditioned on the key COUNT. + const { c } = clientAnswering({ + ...ENVELOPE, + lock: { locked: false, reason: 'ok' }, + provenance: { origin: 'package' }, + }); + expect(await c.get('object', 'showcase_project')).toEqual(OBJECT_BODY); + }); + + it('A3: an envelope carrying a null item reads as null', async () => { + const { c } = clientAnswering({ type: 'object', name: 'ghost', item: null }); + expect(await c.get('object', 'ghost')).toBeNull(); + }); +}); + +describe('objectui#4271 · non-envelope responses pass through UNCHANGED', () => { + it('A4: a bare body (older server / already-unwrapped) is returned as-is', async () => { + const { c } = clientAnswering(OBJECT_BODY); + expect(await c.get('object', 'showcase_project')).toEqual(OBJECT_BODY); + }); + + it('A5: type+name WITHOUT an `item` key is not unwrapped', async () => { + // A metadata document may legitimately carry `type` and `name` of its own + // (a view is `{name, type: "grid", ...}`). Absent `item`, it is the body. + const view = { name: 'all_projects', type: 'grid', columns: ['name'] }; + const { c } = clientAnswering(view); + expect(await c.get('view', 'all_projects')).toEqual(view); + }); + + it('A6: an `item` key WITHOUT the envelope identity is not unwrapped', async () => { + // Presence-checked against the spec shape, not duck-guessed off `item` + // alone — a body whose own schema has an `item` property stays whole. + const body = { name: 'cart', item: { sku: 'x' } }; + const { c } = clientAnswering(body); + expect(await c.get('object', 'cart')).toEqual(body); + }); + + it('A7: an array response passes through', async () => { + const { c } = clientAnswering([{ name: 'a' }]); + expect(await c.get('object', 'weird')).toEqual([{ name: 'a' }]); + }); + + it('A8: 404 still reads as null', async () => { + const { c } = clientAnswering({}, { status: 404 }); + expect(await c.get('object', 'missing')).toBeNull(); + }); + + it('A9: error responses still throw with status, code and message intact', async () => { + const { c } = clientAnswering( + { error: { code: 'RESOURCE_FORBIDDEN', message: 'Nope.' } }, + { status: 403, headers: { 'content-type': 'application/json' } }, + ); + await expect(c.get('object', 'secret')).rejects.toThrow('Nope.'); + const err = await c.get('object', 'secret').catch((e: MetadataError) => e); + expect((err as MetadataError).status).toBe(403); + expect((err as MetadataError).code).toBe('RESOURCE_FORBIDDEN'); + }); +}); + +describe('objectui#4271 · the getDraft() envelope asymmetry is preserved', () => { + it('A10: getDraft() returns the RAW envelope, as its docblock promises', async () => { + // Load-bearing: ~11 call sites read `.item` off getDraft (StudioDesignSurface, + // ResourceEditPage, PackageOwdOverviewPanel, ObjectHooksPanel, + // PermissionMatrixEditor). Routing getDraft through the unwrapping get() + // would silently empty every one of them. + const draftEnvelope = { type: 'object', name: 'showcase_project', item: OBJECT_BODY }; + const { c, urls } = clientAnswering(draftEnvelope); + const draft = await c.getDraft>('object', 'showcase_project'); + expect(draft).toEqual(draftEnvelope); + expect((draft as any)?.item).toEqual(OBJECT_BODY); + expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?state=draft'); + }); + + it('A11: get(state:draft) unwraps like any other get()', async () => { + // The SHAPE follows the method's published contract, not the query param: + // `get()` promises the body on every path it serves. + const { c, urls } = clientAnswering(ENVELOPE); + expect(await c.get('object', 'showcase_project', { state: 'draft' })).toEqual(OBJECT_BODY); + expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?state=draft'); + }); + + it('A12: URL construction is untouched by the unwrap', async () => { + const { c, urls } = clientAnswering(ENVELOPE); + await c.get('object', 'has spaces', { packageId: 'app.crm' }); + expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/has%20spaces?package=app.crm'); + const preview = new MetadataClient({ + baseUrl: 'http://localhost:3000', + previewDrafts: true, + fetch: mockFetch(async (url) => { + urls.push(url); + return jsonResponse(ENVELOPE); + }), + }); + // The overlay flag still rides, and the preview read unwraps too. + expect(await preview.get('object', 'showcase_project')).toEqual(OBJECT_BODY); + expect(urls[1]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?preview=draft'); + }); +}); diff --git a/packages/data-objectstack/src/metadata-client.ts b/packages/data-objectstack/src/metadata-client.ts index f359145e9..5566572be 100644 --- a/packages/data-objectstack/src/metadata-client.ts +++ b/packages/data-objectstack/src/metadata-client.ts @@ -461,6 +461,32 @@ async function parseError(res: Response): Promise { return err; } +/** + * Is this the single-item response envelope, as opposed to a metadata document? + * + * Matched by the PRESENCE of the three keys `GetMetaItemResponseSchema` + * declares — `type: string`, `name: string`, and an `item` slot — never by + * guessing from the payload's contents. The distinction matters in both + * directions: + * + * - A metadata document may legitimately carry its own `type` and `name` + * (a view is `{ name, type: 'grid', … }`). Without an `item` key it is the + * body, and unwrapping it would destroy it. + * - A document could carry an `item` property of its own. Without the + * envelope's identity keys it is likewise left whole. + * + * Key COUNT is deliberately not part of the test: a real envelope also spreads + * `MetadataProtectionEnvelopeFields` (`lock`, `provenance`, …), so it routinely + * has more than three keys. + */ +function isMetaItemEnvelope( + value: unknown, +): value is { type: string; name: string; item: unknown } { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const env = value as Record; + return typeof env.type === 'string' && typeof env.name === 'string' && 'item' in env; +} + /** * MetadataClient — read/write protocol metadata via the framework REST API. * @@ -594,11 +620,16 @@ export class MetadataClient { } /** - * Get a single metadata item. Returns the unwrapped item content - * (matching the framework REST handler which calls `res.json(item)`). - * Returns `null` on 404 to keep the call-site ergonomic. + * Fetch a single metadata item and return the response body EXACTLY as the + * server sent it — envelope and all. + * + * The one transport both {@link get} (which unwraps) and {@link getDraft} + * (which does not) sit on, so the two can differ in what they hand back + * without differing in how they ask. Private on purpose: the envelope is a + * wire detail, and the two published contracts above are the supported ways + * to read an item. */ - async get( + private async readItemResponse( type: string, name: string, options: MetadataGetOptions = {}, @@ -617,24 +648,63 @@ export class MetadataClient { return (await res.json()) as T; } + /** + * Get a single metadata item. Returns the unwrapped item CONTENT — the + * metadata document itself, so `obj.fields` / `obj.label` are reachable + * directly. + * + * The framework answers `GET /meta/:type/:name` with the spec-declared + * envelope `{ type, name, item, …protection fields }` + * (`GetMetaItemResponseSchema`; objectstack#5563 collapsed this read to that + * ONE shape), so the envelope is unwrapped here — at the client boundary, + * once — rather than by each caller. + * + * **This method used to return the envelope while promising the body** + * (objectui#4271). Nothing detected the disagreement, because the test + * doubles across the repo were written against this docblock: every consumer + * reading `obj.fields` got `undefined` in production and a field list in + * unit tests. The visible cost was the entire field half of the permission + * matrix reporting "No fields registered for this object." for every object, + * plus dead RLS CEL autocomplete, an inert report drill-down and a designer + * that saved the envelope back over the object body. + * + * A response that is NOT the envelope (an older server answering the bare + * document, or a body of its own shape) passes through untouched, and `null` + * still comes back on 404 to keep the call site ergonomic. + */ + async get( + type: string, + name: string, + options: MetadataGetOptions = {}, + ): Promise { + const resp = await this.readItemResponse(type, name, options); + if (isMetaItemEnvelope(resp)) return (resp.item ?? null) as T | null; + return resp as T | null; + } + /** * Read the pending draft body for an item (`?state=draft`). Returns * `null` when there is no draft pending. Draft reads do NOT fall * back to the published overlay or the artifact registry — a `null` * unambiguously means "nothing to publish". * - * Note: the framework wraps draft responses in an envelope - * `{ type, name, item }` (matching `getMetaItem`); callers should - * read `.item` to get the body. The legacy `get()` returns the - * unwrapped body, so this method preserves that asymmetry by - * returning whatever the server sent. + * Note: this method hands back the `{ type, name, item }` envelope the + * framework sends, NOT the body — callers read `.item`. That asymmetry with + * {@link get} is deliberate and long-standing (the draft envelope's identity + * and protection carriers are part of what a draft reader inspects), so it + * is preserved by reading the transport directly instead of going through + * `get()`'s unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft` + * (this package) are the shared helpers for taking the body out. */ async getDraft( type: string, name: string, options: { packageId?: string } = {}, ): Promise { - return this.get(type, name, { state: 'draft', ...(options.packageId ? { packageId: options.packageId } : {}) }); + return this.readItemResponse(type, name, { + state: 'draft', + ...(options.packageId ? { packageId: options.packageId } : {}), + }); } /**