diff --git a/.changeset/8181-draft-envelope-read-decoration-strip.md b/.changeset/8181-draft-envelope-read-decoration-strip.md new file mode 100644 index 0000000000..42968d7ac0 --- /dev/null +++ b/.changeset/8181-draft-envelope-read-decoration-strip.md @@ -0,0 +1,43 @@ +--- +'@object-ui/data-objectstack': patch +'@object-ui/app-shell': patch +--- + +fix(studio): one draft-envelope reader, and it strips the framework's read decorations + +`client.getDraft()` serves a DECORATED body — the draft branch stamps +`_draft: true` and then `decorateMetadataItem` attaches `_diagnostics` for any +type with a registered Zod schema. The spec names both READ-TIME decorations +precisely because a served body "is NOT a valid input to the schema that +produced it until these are removed" (`METADATA_READ_DECORATIONS`). + +objectui#7603 taught `ResourceEditPage` to strip them. It could only teach one +site, because `extractDraftBody` existed **four times** — three verbatim copies +plus a hand-rolled one in `ObjectHooksPanel` — and six more consumers unwrapped +the envelope inline. Ten readers, one of which knew the rule. + +**The user-visible half.** The pending-changes sheet's per-entry diff compares +the published body against the draft body key by key. Those two reads are +decorated ASYMMETRICALLY — only the draft branch stamps `_draft` — so the sheet +listed `_draft` under "Also changed:" on every entry that has a published +counterpart, and `_diagnostics` alongside it whenever the two read-time verdicts +differed. Framework-internal keys were being presented to the author as their +own edits, on the screen where they decide whether to publish. + +**The rest.** Six sites merged a decorated body into a document they then wrote +back through `save(..., { mode: 'draft' })` — the Studio app / page / object / +flow surfaces, the package OWD panel, the object hooks panel, and the +adapter's `updateView`. Today's server absorbs that (it strips read decorations +on ingress, before its own schema gate), so nothing 400s; this is still a client +emitting a body its own spec calls invalid, and the fix belongs at the producer. + +The cure is one function rather than ten strips: `extractDraftBody` is now +exported from `@object-ui/data-objectstack`, beside the `getDraft` whose +envelope it decodes. The key list is the spec's exported +`stripReadDecorations` — never a second hand-maintained copy in this repo. The +presence verdict still runs BEFORE the strip, so removing our own annotations +can never turn a served draft into "nothing pending", and the ADR-0010 +protection envelope (`_lock`, `_provenance`, `_packageId`, `_packageVersion`) +is deliberately untouched: those keys are declared by the closed schemas. + +No schema was loosened, and no gate was taught to tolerate `_diagnostics`. diff --git a/packages/app-shell/src/preview/DraftChangesPanel.tsx b/packages/app-shell/src/preview/DraftChangesPanel.tsx index 79a14c83e1..0526051f6f 100644 --- a/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -69,6 +69,7 @@ import { useObjectTranslation } from '@object-ui/i18n'; // refuses, and a faithful copy is exactly the fork that guard exists to prevent. import { fetchPendingDrafts } from './usePendingDrafts.js'; import { canonicalMetaUrlType } from '@objectstack/spec/shared'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; import { diffFields } from '../views/metadata-admin/previews/object-fields-io.js'; // The live `?surface=` channel, and NOT `useSurfaceDeepLink` beside it: this // import must stay React-only, because that module reaches `nav-selection.js` @@ -151,11 +152,29 @@ async function publishedNamesOf(type: string): Promise> { * Some framework reads wrap the body in a `{ type, name, item }` envelope * (draft reads do; published reads return the bare body). Unwrap defensively. */ +/** + * Take the body out of a `/meta` response, decoration-free (objectui#8181). + * + * ⚠️ The strip is not cosmetic here — it is what makes the review diff below + * TRUE. `computeChangeDetail` compares every top-level key of the published + * body against the draft body, and the framework decorates the two reads + * ASYMMETRICALLY: the draft branch stamps `_draft: true` before decorating, + * the published branch does not. So `_draft` differed on every entry that has + * a published counterpart, and the sheet listed it as a key this publish + * changes — a framework-internal key presented to the author as their own + * edit, on the door where they decide whether to publish. `_diagnostics` rides + * the same asymmetry whenever the two bodies' read-time verdicts differ. + * + * The key list is the spec's (`METADATA_READ_DECORATIONS`), never a local + * copy. The ADR-0010 protection envelope is deliberately NOT on it: those keys + * are declared by the closed schemas, so a real change to one of them IS a + * change this diff should report. + */ function unwrapItem(payload: unknown): Record | null { if (!payload || typeof payload !== 'object') return null; const p = payload as Record; - if (p.item && typeof p.item === 'object') return p.item as Record; - return p; + const body = p.item && typeof p.item === 'object' ? (p.item as Record) : p; + return stripReadDecorations(body) as Record; } async function fetchItemBody( diff --git a/packages/app-shell/src/preview/__tests__/DraftChangesPanel.readDecorationStrip.test.tsx b/packages/app-shell/src/preview/__tests__/DraftChangesPanel.readDecorationStrip.test.tsx new file mode 100644 index 0000000000..067c2234c6 --- /dev/null +++ b/packages/app-shell/src/preview/__tests__/DraftChangesPanel.readDecorationStrip.test.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The publish-review diff reports what the AUTHOR changed, never what the + * framework decorated (objectui#8181). + * + * ## The defect, and why it was live rather than latent + * + * `EntryDetail` fetches the item twice — published, then `?state=draft` — and + * `computeChangeDetail` reports every top-level key whose value differs. The + * framework decorates those two reads ASYMMETRICALLY: the draft branch stamps + * `_draft: true` on the row before handing it to `decorateMetadataItem`, and + * the published branch does not stamp anything. `unwrapItem` took the body + * verbatim, so `_draft` differed on EVERY entry that has a published + * counterpart, and the sheet listed it under "Also changed:" — a + * framework-internal key rendered to the author as one of their own edits, on + * the screen where they decide whether to publish. `_diagnostics` joins it + * whenever the two reads' verdicts differ, which is the normal case for a + * draft that changed anything. + * + * This is the half of objectui#8181 that needed no failure arm and no schema + * gate to be wrong: it is wrong on the happy path, every time, in front of the + * author. + * + * ## Why the fixture carries BOTH reads + * + * ⚠️ Drop the published read (make the entry NEW) and this passes with the + * defect fully present: `computeChangeDetail` short-circuits `pub` to `{}` and + * every key is "changed", so the decoration hides in a list that is expected to + * be long. The published-vs-draft PAIR is the trigger. + * + * ## The control + * + * `label` differs between the two bodies on purpose. It MUST appear under + * "Also changed:" — that is what proves the strip took the framework's keys and + * not the diff itself. Without it, a `unwrapItem` that returned `null` for + * everything would pass every "not.toContain" assertion in this file. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('@object-ui/i18n', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useObjectTranslation: () => ({ + t: (_k: string, o?: { defaultValue?: string; count?: number }) => + (o?.defaultValue ?? _k).replace('{{count}}', String(o?.count ?? '')), + }), + }; +}); + +import { DraftChangesPanel } from '../DraftChangesPanel'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const PUBLISHED = { + name: 'crmext_visit', + label: 'Visit', + sharingModel: 'private', + fields: { name: { type: 'text' } }, + // A published read is decorated too — only the `_draft` stamp is draft-only. + _diagnostics: { valid: true, errors: [] }, +}; + +const DRAFT = { + name: 'crmext_visit', + label: 'Customer Visit', // ← the CONTROL: a real authored change + sharingModel: 'private', + fields: { name: { type: 'text' } }, + _diagnostics: { valid: false, errors: [{ path: 'label', message: 'x' }] }, + _draft: true, +}; + +function mockRoutes() { + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + if (url.includes('/_drafts')) { + return ok([{ type: 'object', name: 'crmext_visit', packageId: 'com.test.crmext' }]); + } + if (url.includes('state=draft')) { + return ok({ type: 'object', name: 'crmext_visit', item: DRAFT }); + } + if (/\/meta\/object\/crmext_visit/.test(url)) { + return ok({ type: 'object', name: 'crmext_visit', item: PUBLISHED }); + } + if (/\/meta\/object(\?|$)/.test(url)) return ok([{ name: 'crmext_visit' }]); + return { ok: false, status: 404, json: async () => ({}) }; + }) as unknown as typeof fetch; +} + +async function openEntryDetail() { + render( + {}} packageId="com.test.crmext" onPublish={vi.fn()} />, + ); + const toggle = await screen.findByTestId('draft-entry-toggle'); + fireEvent.click(toggle); + return screen.findByTestId('draft-entry-detail', undefined, { timeout: 4000 }); +} + +describe('DraftChangesPanel — read decorations never reach the review diff (objectui#8181)', () => { + it('does not report `_draft` or `_diagnostics` as keys this publish changes', async () => { + mockRoutes(); + const detail = await openEntryDetail(); + + // The CONTROL first: the real authored change IS reported, so the diff ran + // and this harness reaches the changed-keys strip. + await waitFor(() => expect(detail.textContent).toContain('label')); + + // …and the framework's own keys are not sitting next to it. `_draft` is the + // deterministic one — the server stamps it on the draft read and never on + // the published read, so before the fix it was named on EVERY entry. + expect(detail.textContent).not.toContain('_draft'); + expect(detail.textContent).not.toContain('_diagnostics'); + }); + + it('still reports nothing at all when only the decorations differ', async () => { + // The same body on both sides except for the framework's stamps: the honest + // answer is "the draft matches the published version", and before the fix + // it was "Also changed: _diagnostics, _draft". + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + const AUTHORED = { name: 'crmext_visit', label: 'Visit', fields: { name: { type: 'text' } } }; + if (url.includes('/_drafts')) { + return ok([{ type: 'object', name: 'crmext_visit', packageId: 'com.test.crmext' }]); + } + if (url.includes('state=draft')) { + return ok({ + type: 'object', + name: 'crmext_visit', + item: { ...AUTHORED, _draft: true, _diagnostics: { valid: false, errors: [{ x: 1 }] } }, + }); + } + if (/\/meta\/object\/crmext_visit/.test(url)) { + return ok({ + type: 'object', + name: 'crmext_visit', + item: { ...AUTHORED, _diagnostics: { valid: true, errors: [] } }, + }); + } + if (/\/meta\/object(\?|$)/.test(url)) return ok([{ name: 'crmext_visit' }]); + return { ok: false, status: 404, json: async () => ({}) }; + }) as unknown as typeof fetch; + + render( + {}} packageId="com.test.crmext" onPublish={vi.fn()} />, + ); + fireEvent.click(await screen.findByTestId('draft-entry-toggle')); + + await waitFor( + () => expect(screen.getByText(/No differences detected/)).toBeInTheDocument(), + { timeout: 4000 }, + ); + // The strip is what produced that verdict, so the detail block — which only + // renders when there IS something to report — must be absent. + expect(screen.queryByTestId('draft-entry-detail')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/preview/capabilityLint.ts b/packages/app-shell/src/preview/capabilityLint.ts index f579d7916b..43920cda4e 100644 --- a/packages/app-shell/src/preview/capabilityLint.ts +++ b/packages/app-shell/src/preview/capabilityLint.ts @@ -24,6 +24,8 @@ * capability may legitimately be provided by another installed package). */ +import { stripReadDecorations } from '@objectstack/spec/kernel'; + interface PendingDraft { type: string; name: string; @@ -83,9 +85,21 @@ export async function lintDraftCapabilityReferences( const linted = pending.filter((d) => LINTED_DRAFT_TYPES.has(d.type)); if (linted.length === 0) return []; + // Read decorations do not reach the rule (objectui#8181). MEASURED: neither + // `validateSecurityPosture` nor `validateCapabilityReferences` moves its + // verdict on a decorated body today (control: an object with no + // `sharingModel` fires `security-owd-unset` in both directions), so this is + // defence in depth rather than a fix. It is here because the alternative is + // leaving one more verbatim copy of the unwrap that omits the strip, which + // is the shape objectui#8181 exists to end: the rule should read the + // document the AUTHOR wrote, so a future rule that enumerates keys cannot + // inherit the defect. The key list is the spec's, never a local copy; the + // ADR-0010 protection envelope is not on it and survives. const unwrap = (raw: unknown): Record | null => { const item = (raw as { item?: unknown })?.item ?? raw; - return item && typeof item === 'object' ? (item as Record) : null; + return item && typeof item === 'object' + ? (stripReadDecorations(item) as Record) + : null; }; const bodies = await Promise.all( diff --git a/packages/app-shell/src/preview/securityPostureLint.ts b/packages/app-shell/src/preview/securityPostureLint.ts index 0c4bb799e9..d9461d5399 100644 --- a/packages/app-shell/src/preview/securityPostureLint.ts +++ b/packages/app-shell/src/preview/securityPostureLint.ts @@ -55,6 +55,8 @@ * pull the whole lint bundle onto the eager console graph. */ +import { stripReadDecorations } from '@objectstack/spec/kernel'; + interface PendingDraft { type: string; name: string; @@ -124,9 +126,21 @@ export async function lintDraftSecurityPosture( const objects = pending.filter((d) => d.type === 'object'); if (objects.length === 0) return []; + // Read decorations do not reach the rule (objectui#8181). MEASURED: neither + // `validateSecurityPosture` nor `validateCapabilityReferences` moves its + // verdict on a decorated body today (control: an object with no + // `sharingModel` fires `security-owd-unset` in both directions), so this is + // defence in depth rather than a fix. It is here because the alternative is + // leaving one more verbatim copy of the unwrap that omits the strip, which + // is the shape objectui#8181 exists to end: the rule should read the + // document the AUTHOR wrote, so a future rule that enumerates keys cannot + // inherit the defect. The key list is the spec's, never a local copy; the + // ADR-0010 protection envelope is not on it and survives. const unwrap = (raw: unknown): Record | null => { const item = (raw as { item?: unknown })?.item ?? raw; - return item && typeof item === 'object' ? (item as Record) : null; + return item && typeof item === 'object' + ? (stripReadDecorations(item) as Record) + : null; }; const bodies = await Promise.all( diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 3dbb8bfe68..0eae3b9d58 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -39,6 +39,7 @@ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; import { Save, Loader2, @@ -450,8 +451,16 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // envelope the display baseline comes from, so the writability verdict // and the body on screen can never be read from different round trips. setCodeIsArtifact(isArtifactBackedLayer(lay)); + // Read decorations do NOT seed the editor (objectui#8181). `doSave` + // below re-bases on a fresh RAW `layered` read, which drops them — but + // its `.catch(() => null)` arm falls back to this very body and + // spreads it into `client.save`, so a failed layered read used to put + // `_diagnostics` / `_draft` on the wire. Strip at the unwrap, which is + // the one place the served envelope becomes an editable draft. const draftBody = pendingDraft - ? (((pendingDraft as any).item ?? pendingDraft) as PermissionSetDraft) + ? (stripReadDecorations( + (pendingDraft as any).item ?? pendingDraft, + ) as PermissionSetDraft) : null; // Draft wins over the published baseline for display (D6). const effective: PermissionSetDraft = (draftBody ?? lay?.effective ?? diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index d5c59d3a78..1be18d2184 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -85,6 +85,10 @@ import type { MetadataLockState, MetadataReference, } from '@object-ui/data-objectstack'; +// The ONE draft-envelope reader (objectui#8181). Its docblock carries the +// read-decoration contract objectui#7603 established here; it was hoisted so +// the three sibling copies could stop re-deriving it. +import { extractDraftBody } from '@object-ui/data-objectstack'; import { PageShell } from './PageShell.js'; import { MetadataTypeActions } from './MetadataTypeActions.js'; import { LayeredDiff, countOverlaidFields } from './LayeredDiff.js'; @@ -127,7 +131,6 @@ import { validateMetadataDraft, hasClientValidator, type DraftMode } from './cli import { describeIssuePath } from './issuePath.js'; import { buildCreateModeBody } from './createBody.js'; import { errorCodeIs, errorCodeIsAnyOf } from '@object-ui/types'; -import { stripReadDecorations } from '@objectstack/spec/kernel'; /** * ADR-0010 §3.6 lock state -> the lock banner's headline sentence. @@ -205,62 +208,6 @@ const CANVAS_OWNED_KEYS: Record = { object: ['fields', 'fieldGroups'], }; -/** - * Normalize the framework's draft envelope into either the draft body or - * `null` (no pending draft). The envelope is: - * - * - `{ type, name, item: {...} }` when a draft exists, - * - `{ type, name, label }` when no draft exists (HTTP 200, item absent). - * - * The presence of the `item` key is the single signal; we do NOT fall back - * to using the envelope itself as the body — doing so would mis-identify the - * "no draft" stub (which still has `type`/`name`/`label` keys) as a real - * pending draft and would corrupt the editor baseline. - * - * ## The served body is DECORATED, and this is where that stops (objectui#7603) - * - * The strict draft branch returns `item: decorateMetadataItem(type, …)`, which - * attaches `_diagnostics` whenever the type has a registered Zod schema, and - * `_draft` on the preview-draft branch. The spec calls both a READ-TIME - * decoration and says a served body "is therefore NOT a valid input to the - * schema that produced it until these are removed". Every merge site below - * spreads this body over the layered baseline (`{ ...baseline, ...draftReal }`) - * and the result reaches the client Zod gate, so the decoration made 14 of the - * 15 wired types — every one whose schema is `.strict()` — report a body THE - * SERVER ACCEPTS as `unrecognized_keys`. The layered half is clean - * (`getMetaItemLayered` serves RAW layers), so the misfire needed a PENDING - * DRAFT to exist, which is why it stayed invisible. - * - * This function is the chokepoint: it is the one place a served draft envelope - * becomes a body, and all three merge sites (the load effect, the post-save - * refresh, the post-publish refresh) read it. Stripping here fixes them - * together and leaves no fourth site to forget. - * - * ⛔ Never by loosening a schema, and ⛔ never with a local - * `['_diagnostics', '_draft']`: the list is the SPEC'S, reached through its own - * exported helper — the same one `MetadataService.saveFields` uses on the write - * side — because a second hand-maintained copy goes stale the next time the - * framework adds a decoration, and a decoration this code does not know to - * remove is precisely the defect. The ADR-0010 protection envelope (`_lock`, - * `_provenance`, …) is deliberately NOT on that list: those keys are - * allowlisted by the closed schemas so provenance survives a re-parse, and this - * strip leaves them alone. - * - * The strip runs AFTER the presence verdict, never before it: what counts as a - * pending draft is `getDraft`'s answer, and removing our own decorations must - * not be able to turn a served draft into "no draft". - */ -function extractDraftBody( - draftResp: unknown, -): Record | null { - if (!draftResp || typeof draftResp !== 'object') return null; - const env = draftResp as Record; - if (!('item' in env)) return null; - const body = env.item; - if (!body || typeof body !== 'object') return null; - if (Object.keys(body as object).length === 0) return null; - return stripReadDecorations(body) as Record; -} /** * The software-package binding this editor is authoring under, read from the 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 0ab3717b39..0490bfa1dd 100644 --- a/packages/app-shell/src/views/runtime-metadata-persistence.test.ts +++ b/packages/app-shell/src/views/runtime-metadata-persistence.test.ts @@ -265,6 +265,43 @@ describe('runtime-metadata-persistence seam (ADR-0034)', () => { expect(unwrapDraftBody('x')).toBeNull(); expect(unwrapDraftBody({})).toBeNull(); }); + + /** + * objectui#8181 — the read decorations come off HERE, at the one place the + * served body becomes a body this seam hands to callers. + * + * `readRuntimeDraft`'s answer is what `RuntimeDraftBar` passes to + * `onResume`, which seeds the host editor whose next save writes it back. + * Leaving `_diagnostics` / `_draft` on it completes exactly the read→edit→ + * write round trip the spec's `stripReadDecorations` exists to break. + */ + describe('read decorations (objectui#8181)', () => { + const DECOR = { _diagnostics: { valid: true, errors: [] }, _draft: true }; + + it('strips them off the envelope limb', () => { + expect( + unwrapDraftBody({ type: 'view', name: 'v', item: { a: 1, ...DECOR } }), + ).toEqual({ a: 1 }); + }); + + it('strips them off the bare-body limb — the one get() actually feeds', () => { + expect(unwrapDraftBody({ a: 1, ...DECOR })).toEqual({ a: 1 }); + }); + + it('keeps the ADR-0010 protection envelope, which the schemas declare', () => { + expect( + unwrapDraftBody({ a: 1, _lock: { locked: true }, _provenance: 'package', ...DECOR }), + ).toEqual({ a: 1, _lock: { locked: true }, _provenance: 'package' }); + }); + + it('a draft carrying ONLY decorations still reads as pending, not as null', () => { + // The verdict runs before the strip: `!!readRuntimeDraft(...)` is the + // "has pending changes" test, and a served draft must never become + // "nothing pending" because we removed our own annotations from it. + expect(unwrapDraftBody({ type: 'view', name: 'v', item: { ...DECOR } })).toEqual({}); + expect(unwrapDraftBody({ ...DECOR })).toEqual({}); + }); + }); }); /** diff --git a/packages/app-shell/src/views/runtime-metadata-persistence.ts b/packages/app-shell/src/views/runtime-metadata-persistence.ts index 2b6b3d70be..0e0bbf5290 100644 --- a/packages/app-shell/src/views/runtime-metadata-persistence.ts +++ b/packages/app-shell/src/views/runtime-metadata-persistence.ts @@ -28,6 +28,7 @@ * the call site. */ +import { stripReadDecorations } from '@objectstack/spec/kernel'; import { slugify } from './metadata-admin/createDerive.js'; /** The runtime-editable artifact types ADR-0034 unifies. `page` (a record @@ -155,7 +156,26 @@ function invalidateViewCaches( * The framework wraps draft reads in a `{ type, name, item }` envelope; a * published read is the bare body. This accepts either shape and returns * `null` for an empty/absent draft so `!!readRuntimeDraft(...)` is a reliable - * "has pending changes" check. Mirrors studio's `extractDraftBody`. + * "has pending changes" check. + * + * The tolerant sibling of `@object-ui/data-objectstack`'s `extractDraftBody`, + * and the tolerance is load-bearing rather than defensive: `readRuntimeDraft` + * reads through `MetadataClient.get()`, which UNWRAPS the envelope at the + * client boundary (objectui#4271), so the bare-body limb is the normal path + * here and the envelope limb covers a body arriving by any other route. That + * is the only reason this is a separate function; the two split on which + * client method feeds them, not on what they mean. + * + * ⚠️ It mirrors that helper's read-decoration strip too (objectui#8181): the + * body this returns is handed to `RuntimeDraftBar`'s `onResume`, which seeds + * the host editor whose next save writes it back, so a served `_diagnostics` / + * `_draft` would complete the round trip the spec's `stripReadDecorations` + * exists to break. The key list is the spec's, never a copy; the ADR-0010 + * protection envelope is deliberately not on it and survives untouched. + * + * The emptiness verdict is taken BEFORE the strip — what counts as a pending + * draft is the server's answer, and removing our own decorations must not be + * able to turn a served draft into "no draft". */ export function unwrapDraftBody( resp: unknown, @@ -166,10 +186,12 @@ export function unwrapDraftBody( const body = env.item; if (!body || typeof body !== 'object') return null; return Object.keys(body as object).length > 0 - ? (body as Record) + ? (stripReadDecorations(body) as Record) : null; } - return Object.keys(env).length > 0 ? env : null; + return Object.keys(env).length > 0 + ? (stripReadDecorations(env) as Record) + : null; } /** diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx index c148b2372a..ada5d618d8 100644 --- a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx @@ -29,6 +29,7 @@ import { SchemaForm } from '../metadata-admin/SchemaForm.js'; import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry.js'; import { useMetadataClient } from '../metadata-admin/useMetadata.js'; import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; +import { extractDraftBody } from '@object-ui/data-objectstack'; import { formatMetadataError } from './metadataError.js'; interface HookItem { @@ -39,12 +40,16 @@ interface HookItem { [key: string]: unknown; } -/** The body out of a getDraft() envelope (`{ item: {...} }`). */ -function draftBody(resp: unknown): HookItem | null { - if (!resp || typeof resp !== 'object' || !('item' in resp)) return null; - const body = (resp as { item?: unknown }).item; - return body && typeof body === 'object' && Object.keys(body).length > 0 ? (body as HookItem) : null; -} +/** + * The body out of a `getDraft()` envelope, decoration-free (objectui#8181). + * + * This was a hand-rolled fourth copy of `extractDraftBody` that skipped the + * read-decoration strip, so a served `_diagnostics` / `_draft` rode the hook + * body into `save('hook', ...)` below. The unwrap is the shared one now; only + * the `HookItem` narrowing is local. + */ +const draftBody = (resp: unknown): HookItem | null => + extractDraftBody(resp) as HookItem | null; /** Does this hook's `object` target match the object we're viewing? */ function targetsObject(hook: HookItem, objectName: string): boolean { diff --git a/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.readDecorationStrip.test.tsx b/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.readDecorationStrip.test.tsx new file mode 100644 index 0000000000..8e91c68a0a --- /dev/null +++ b/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.readDecorationStrip.test.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The OWD panel's package-scoped save carries no read decorations + * (objectui#8181). + * + * ## Why this site and not one of the other three + * + * `doSave` here is the shortest complete round trip in the sweep, and the only + * one with no mitigating arm: it re-reads `layered` ∪ `getDraft`, spreads the + * DRAFT BODY over the baseline, applies the OWD pair, and PUTs the result — + * `{ ...baseline, ...draftBody }` straight into `client.save('object', ...)`. + * `PermissionMatrixEditor` re-bases on a fresh RAW layered read first (so it + * leaks only when that read fails), and the two `StudioDesignSurface` writes + * go through the same hoisted reader this file exercises. So this is the + * cheapest honest proof that the hoist reaches a WRITE, not just a helper. + * + * ## What "reaches a write" does and does not mean here + * + * ⚠️ Measured, and stated so nobody re-derives it from this file's existence: + * today's server does NOT 400 on this. `saveMetaItem` strips read decorations + * on ingress, deliberately placed before its schema gate, so the body is + * laundered on the far side of the wire. That is a mitigation in the framework, + * not a licence for this client to emit a body its own spec calls invalid — + * `ObjectSchema.safeParse(body + _diagnostics)` answers `unrecognized_keys` at + * the root. AGENTS.md #0.1: one strict contract, fixed at the producer. + * + * ## The control + * + * The panel must still save the OWD edit and still carry the author's own + * keys. Both are asserted alongside the absences — without them a `save` that + * shipped `{}` would pass every `not.toHaveProperty` in this file. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { PackageOwdOverviewPanel } from './PackageOwdOverviewPanel'; + +/** Exactly what the framework attaches to a `?state=draft` read. */ +const DECORATIONS = { _diagnostics: { valid: true, errors: [] }, _draft: true }; +/** ADR-0010 carriers: declared by the schemas, must survive the strip. */ +const PROTECTION = { _provenance: 'package', _packageId: 'com.example.showcase' }; + +const PUBLISHED = { + crm_contact: { name: 'crm_contact', label: 'Contact', sharingModel: 'private' }, +}; + +/** The pending draft the server serves, decorated the way it really is. */ +const SERVED_DRAFT = { + name: 'crm_contact', + label: 'Contact', + description: 'authored in the draft', // the author's own key — must survive + sharingModel: 'private', + ...PROTECTION, + ...DECORATIONS, +}; + +const saved: Array<{ name: string; body: Record }> = []; + +function makeClient() { + return { + list: async (type: string) => + type === 'object' + ? Object.entries(PUBLISHED).map(([name, b]) => ({ name, label: b.label })) + : [], + listDrafts: async () => [], + layered: async (_t: string, name: string) => ({ + effective: PUBLISHED[name as keyof typeof PUBLISHED] ?? {}, + code: null, + }), + // The decorated envelope, on BOTH the load read and the save-time re-read. + getDraft: async () => ({ type: 'object', name: 'crm_contact', item: SERVED_DRAFT }), + save: async (_t: string, name: string, body: Record) => { + saved.push({ name, body }); + return body; + }, + } as any; +} + +beforeEach(() => { + saved.length = 0; + Element.prototype.scrollIntoView = vi.fn(); +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('PackageOwdOverviewPanel — read decorations never reach the save (objectui#8181)', () => { + it('writes the authored body without `_diagnostics` / `_draft`', async () => { + render( + , + ); + await screen.findByTestId('owd-row-crm_contact'); + + fireEvent.change(screen.getByTestId('owd-internal-crm_contact'), { + target: { value: 'public_read' }, + }); + fireEvent.click(screen.getByTestId('owd-save')); + + // CONTROL: the save really happened, and really carried the edit. Every + // absence assertion below is meaningless without this. + await waitFor(() => expect(saved).toHaveLength(1)); + const body = saved[0]!.body; + expect(saved[0]!.name).toBe('crm_contact'); + expect(body.sharingModel).toBe('public_read'); + // …and the draft's own authored key rode along, which is the whole reason + // the draft body is merged in at all. + expect(body.description).toBe('authored in the draft'); + + // The decorations did not. + expect(body).not.toHaveProperty('_diagnostics'); + expect(body).not.toHaveProperty('_draft'); + + // The protection envelope is not collateral damage — the schemas declare + // these, and dropping them would lose provenance on every OWD save. + expect(body._provenance).toBe('package'); + expect(body._packageId).toBe('com.example.showcase'); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx b/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx index 71f078be27..72fa1eb906 100644 --- a/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx +++ b/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx @@ -29,20 +29,15 @@ import * as React from 'react'; import { ShieldCheck, Save, Loader2, Lock, ArrowUpRight, AlertTriangle } from 'lucide-react'; import type { MetadataClient } from '@object-ui/data-objectstack'; +// The ONE draft-envelope reader (objectui#8181): unwrap AND strip the +// framework's read decorations in one place. This file used to carry its own +// copy that did the unwrap and skipped the strip. +import { extractDraftBody } from '@object-ui/data-objectstack'; import { t, tFormat, type SupportedLocale } from '../metadata-admin/i18n.js'; import { formatMetadataError } from './metadataError.js'; import { isExternalWider, deriveMasterObject } from './owd-sharing.js'; import { toast } from 'sonner'; -/** Normalize the framework draft envelope `{ type, name, item }` → body | null. */ -function extractDraftBody(resp: unknown): Record | null { - if (!resp || typeof resp !== 'object') return null; - const env = resp as Record; - if (!('item' in env)) return null; - const body = env.item; - if (!body || typeof body !== 'object') return null; - return Object.keys(body as object).length > 0 ? (body as Record) : null; -} /** A single object's loaded OWD baseline (already merged over any pending draft). */ interface OwdRow { diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 79a9cf7f0e..3883a0b61c 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -17,6 +17,10 @@ import * as React from 'react'; import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'; import { useAdapter, SchemaRendererProvider } from '@object-ui/react'; +// The ONE draft-envelope reader (objectui#8181): unwrap AND strip the +// framework's read decorations in one place. This file used to carry its own +// copy that did the unwrap and skipped the strip. +import { extractDraftBody } from '@object-ui/data-objectstack'; import { StudioChatDock } from './StudioAiCopilot.js'; import { nextCenterTab, type StudioCenterTab } from './centerTab.js'; import { useIsWideViewport } from './wideViewport.js'; @@ -241,15 +245,6 @@ const KIND_ICON: Record = { }; const navIcon = (type?: string): LucideIcon => KIND_ICON[type ?? ''] ?? Compass; -/** Normalize the framework draft envelope `{ type, name, item }` → body | null. */ -function extractDraftBody(resp: unknown): Record | null { - if (!resp || typeof resp !== 'object') return null; - const env = resp as Record; - if (!('item' in env)) return null; - const body = env.item; - if (!body || typeof body !== 'object') return null; - return Object.keys(body as object).length > 0 ? (body as Record) : null; -} /** Top-bar package switcher: list app packages (可写 base vs 只读 code), switch by * navigation, create a new writable base via the standard CreatePackageDialog, diff --git a/packages/data-objectstack/src/draft-envelope.test.ts b/packages/data-objectstack/src/draft-envelope.test.ts new file mode 100644 index 0000000000..dad24941fa --- /dev/null +++ b/packages/data-objectstack/src/draft-envelope.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins the ONE draft-envelope reader (objectui#8181). + * + * ## What this file is defending + * + * `extractDraftBody` used to exist four times — three copies spelled + * identically in `ResourceEditPage`, `StudioDesignSurface` and + * `PackageOwdOverviewPanel`, plus a hand-rolled fourth in `ObjectHooksPanel`. + * objectui#7603 taught exactly one of them to strip the framework's read + * decorations. Hoisting the function is what makes "the next copy omits the + * strip again" impossible, and these cases are what keep the hoisted one + * honest. + * + * ## The three properties, and why none is optional + * + * 1. **The decorations come off.** `getDraft()` serves + * `item: decorateMetadataItem(type, ...)`, which attaches `_diagnostics` + * for any type with a registered Zod schema, and the draft branch stamps + * `_draft: true` first. The spec says a served body "is therefore NOT a + * valid input to the schema that produced it until these are removed". + * 2. **The ADR-0010 protection envelope does NOT come off.** `_lock`, + * `_provenance`, `_packageId`, `_packageVersion` share the underscore + * spelling and are DECLARED by the closed schemas so provenance survives a + * re-parse. A strip that took them would be the "drop whatever looks + * internal" pass AGENTS.md #0.1 bans, and it would make the publish-review + * diff blind to a real provenance change. + * 3. **The presence verdict runs BEFORE the strip.** What counts as a pending + * draft is the server's answer. A draft carrying nothing but decorations is + * still a served draft: it must come back as an EMPTY OBJECT (truthy — the + * callers all test `!!body` for "has pending changes"), never as `null`. + * Reversing the order silently converts "there is a draft to publish" into + * "nothing pending", which is the worse failure of the two. + */ + +import { describe, it, expect } from 'vitest'; +import { METADATA_READ_DECORATIONS } from '@objectstack/spec/kernel'; +import { extractDraftBody } from './draft-envelope'; + +/** The decorations exactly as the framework serves them on a draft read. */ +const DECORATIONS = { _diagnostics: { valid: true, errors: [] }, _draft: true }; + +/** ADR-0010 protection carriers — declared by the schemas, never stripped. */ +const PROTECTION = { + _lock: { locked: true }, + _provenance: 'package', + _packageId: 'crmext', + _packageVersion: '1.2.0', +}; + +const envelope = (item: unknown) => ({ type: 'object', name: 'crmext_visit', item }); + +describe('extractDraftBody — the hoisted draft-envelope reader (objectui#8181)', () => { + it('is the spec list that gets removed, not a local one', () => { + // The control for every "was it stripped?" assertion below: if the spec + // ever adds a third decoration, this file must be read again. + expect([...METADATA_READ_DECORATIONS]).toEqual(['_diagnostics', '_draft']); + }); + + it('removes the read decorations and leaves the authored body untouched', () => { + const body = extractDraftBody( + envelope({ name: 'crmext_visit', label: 'Visit', fields: { a: {} }, ...DECORATIONS }), + ); + expect(body).toEqual({ name: 'crmext_visit', label: 'Visit', fields: { a: {} } }); + expect(Object.keys(body!)).not.toContain('_diagnostics'); + expect(Object.keys(body!)).not.toContain('_draft'); + }); + + it('leaves the ADR-0010 protection envelope alone', () => { + const body = extractDraftBody( + envelope({ name: 'crmext_visit', ...PROTECTION, ...DECORATIONS }), + )!; + // Every protection carrier survives BY NAME… + for (const [k, v] of Object.entries(PROTECTION)) expect(body[k]).toEqual(v); + // …and the decorations still went, in the same call. Without this half the + // case above would pass on a function that strips nothing at all. + expect(body).not.toHaveProperty('_diagnostics'); + expect(body).not.toHaveProperty('_draft'); + }); + + it('does not mutate the served envelope', () => { + const item = { name: 'crmext_visit', ...DECORATIONS }; + extractDraftBody(envelope(item)); + // The caller may still be holding the response (`getDraft` results are + // asserted on directly in several tests); the strip must be a copy. + expect(item).toHaveProperty('_diagnostics'); + expect(item).toHaveProperty('_draft'); + }); + + it('a draft carrying ONLY decorations is still a pending draft', () => { + // The verdict-before-strip property. `{}` is truthy, `null` is not, and + // every caller reads the difference as "has pending changes". + const body = extractDraftBody(envelope({ ...DECORATIONS })); + expect(body).not.toBeNull(); + expect(body).toEqual({}); + expect(!!body).toBe(true); + }); + + it('answers null for the shapes that mean "nothing pending"', () => { + expect(extractDraftBody(null)).toBeNull(); + expect(extractDraftBody(undefined)).toBeNull(); + expect(extractDraftBody('x')).toBeNull(); + expect(extractDraftBody({})).toBeNull(); // no `item` member at all + expect(extractDraftBody(envelope(undefined))).toBeNull(); + expect(extractDraftBody(envelope(null))).toBeNull(); + expect(extractDraftBody(envelope({}))).toBeNull(); // served empty draft + expect(extractDraftBody(envelope('not-an-object'))).toBeNull(); + }); +}); diff --git a/packages/data-objectstack/src/draft-envelope.ts b/packages/data-objectstack/src/draft-envelope.ts new file mode 100644 index 0000000000..094646f5b5 --- /dev/null +++ b/packages/data-objectstack/src/draft-envelope.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { stripReadDecorations } from '@objectstack/spec/kernel'; + +/** + * Take the body out of a served draft envelope (`{ type, name, item }`) and + * remove the framework's own READ DECORATIONS from it — or answer `null` when + * there is nothing pending. + * + * ## Why this function exists at all (objectui#8181) + * + * It was implemented three separate times — once in `ResourceEditPage`, once in + * `StudioDesignSurface`, once in `PackageOwdOverviewPanel` — plus a fourth + * hand-rolled copy in `ObjectHooksPanel` under a different name. objectui#7603 + * taught exactly ONE of those copies to strip. That is the defect this file + * closes, and it is a different defect from "N consumers forgot": four + * byte-identical copies of a rule mean the next copy is free to omit it again, + * so the cure is one function rather than four strips. + * + * ## Why it lives HERE and not in `app-shell` + * + * Next to the {@link MetadataClient.getDraft} that produces the envelope this + * decodes. `getDraft` deliberately hands back the wire envelope rather than the + * body (objectui#4271), so the unwrap is part of that method's contract, not a + * detail of any one view. `app-shell` already depends on this package, and the + * one non-app-shell consumer (`updateView`, in this package) is a caller too. + * + * ## The verdict runs BEFORE the strip, never after + * + * What counts as a pending draft is `getDraft`'s answer. A draft whose only + * keys are decorations is still a served draft, so the emptiness verdict is + * taken on the body as it arrived; the strip may return an EMPTY object, but it + * may never turn a served draft into "no draft". Pinned in + * `draft-envelope.test.ts`. + * + * ## Which keys, and which keys deliberately survive + * + * The list is the SPEC'S (`METADATA_READ_DECORATIONS`), reached through its own + * exported helper — the same one `MetadataService.saveFields` uses on the write + * side. Never a second hand-maintained `['_diagnostics', '_draft']` in this + * repo: a local copy goes stale the next time the framework adds a decoration, + * and a decoration this code does not know to remove is precisely the defect. + * + * The ADR-0010 protection envelope (`_lock`, `_provenance`, `_packageId`, + * `_packageVersion`, ...) shares the underscore spelling and is deliberately + * NOT on that list: those keys are declared by the closed schemas so provenance + * survives a re-parse. This strip leaves them alone, and doing otherwise would + * be the same "drop whatever looks internal" pass AGENTS.md #0.1 bans. + * + * ## Not a lenient fallback + * + * It removes exactly the two keys the framework ADDS AT READ TIME and never + * stores. A genuinely unrecognized key still fails loudly at whichever gate + * sees it next. + * + * @param resp the value `MetadataClient.getDraft()` resolved to. + * @returns the decoration-free draft body, or `null` for "nothing pending". + */ +export function extractDraftBody(resp: unknown): Record | null { + if (!resp || typeof resp !== 'object') return null; + const env = resp as Record; + if (!('item' in env)) return null; + const body = env.item; + if (!body || typeof body !== 'object') return null; + if (Object.keys(body as object).length === 0) return null; + return stripReadDecorations(body) as Record; +} diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index b04146ae88..d3a2ea511b 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -22,6 +22,7 @@ import { DroppedFieldsEventSchema } from '@objectstack/spec/data'; // and the wire-side one cannot drift. import { isFilterAST, parseFilterAST } from '@objectstack/spec/data'; import type { ApiError } from '@objectstack/spec/api'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; // #4237 — the metadata save door's advisory reader, shared with `MetadataClient` // rather than forked. ONE reader, two call sites: the other client class calls it // from `MetadataClient.save` (#4133/#4236), this one from the interceptor below. @@ -2404,8 +2405,17 @@ export function narrowPersonalizationOverlay(row: T): T { function unwrapViewDraft(resp: unknown): Record | null { if (!resp || typeof resp !== 'object') return null; const env = resp as Record; - const body = 'item' in env ? env.item : env; - if (!body || typeof body !== 'object') return null; + const raw = 'item' in env ? env.item : env; + if (!raw || typeof raw !== 'object') return null; + // Drop the framework's own read decorations (objectui#8181). `updateView` + // MERGES this body and writes the result back, so without this the served + // `_diagnostics` / `_draft` ride into a `save('view', ...)` — the same + // round-trip `stripReadDecorations` exists to stop, and the same one + // `extractDraftBody` closes on the `getDraft()` side. The key list is the + // spec's; the ADR-0010 protection envelope is not on it and survives. + // Applied to the ITEM, before the artifact wrapper below, because that is + // the level `decorateMetadataItem` decorates. + const body = stripReadDecorations(raw) as Record; // Same `{list: {...}}` artifact wrapper the published read unwraps. const spec = body.list ?? body; if (!spec || typeof spec !== 'object') return null; @@ -6179,6 +6189,10 @@ export type { // Designer surfaces; kept separate from ObjectStackAdapter so callers // can use it without the full data-source surface. export { MetadataClient, readSaveAdvisories } from './metadata-client'; +// The one draft-envelope reader (objectui#8181). Exported beside the +// `getDraft` that produces the envelope, because the unwrap-and-strip is part +// of that method's contract rather than a detail of any one view. +export { extractDraftBody } from './draft-envelope'; export type { RuntimeAuthoringIssue, MetadataSaveAdvisoryEvent, diff --git a/packages/data-objectstack/src/metadata-client.ts b/packages/data-objectstack/src/metadata-client.ts index 83af5ebd4e..cc2a991b04 100644 --- a/packages/data-objectstack/src/metadata-client.ts +++ b/packages/data-objectstack/src/metadata-client.ts @@ -870,8 +870,20 @@ export class MetadataClient { * {@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. + * `get()`'s unwrap. + * + * ⛔ Do NOT hand `.item` to a gate or a write yourself: the body this serves + * is DECORATED (`_draft`, then `_diagnostics` from `decorateMetadataItem`), + * and the spec says a served body is not a valid input to the schema that + * produced it until those come off. {@link extractDraftBody} is the one + * reader for this method's answer — it takes the presence verdict and then + * the strip, in that order. That function used to be copy-pasted into four + * views and only one copy knew the rule (objectui#8181). + * + * Its tolerant siblings exist for the OTHER client method: `unwrapDraftBody` + * (app-shell) and `unwrapViewDraft` (this package) read a draft that arrived + * through {@link get}, which already unwrapped the envelope. They split on + * which method feeds them, not on what they mean — all three strip. */ async getDraft( type: string, diff --git a/packages/data-objectstack/src/updateView.draft.test.ts b/packages/data-objectstack/src/updateView.draft.test.ts index 97cc998625..219a6d7b9e 100644 --- a/packages/data-objectstack/src/updateView.draft.test.ts +++ b/packages/data-objectstack/src/updateView.draft.test.ts @@ -239,4 +239,53 @@ describe('ObjectStackDataSource.updateView — draft addressing (#4139)', () => ).rejects.toThrow(); expect(saveItem).not.toHaveBeenCalled(); }); + /** + * objectui#8181 — the merged draft written back carries no read decorations. + * + * The draft-addressed limb READS a served body and WRITES the merge of it + * straight back, so it is a complete read->edit->write round trip. The + * framework decorates the read (`_diagnostics` for any type with a + * registered schema, plus `_draft: true` on the draft branch), and the spec + * names those keys precisely so they do not go back out: `ViewSchema` and + * every other closed schema refuse them by name. + * + * ⚠️ Today's server absorbs this — `saveMetaItem` strips read decorations on + * ingress before its own schema gate — so the leak is invisible from the + * response. That is a mitigation on the far side of the wire, not a reason + * for this client to put them on it (AGENTS.md #0.1: one strict contract, + * fixed at the producer). What this pin defends is the body this adapter + * SENDS. + */ + it('does not write the framework read decorations back onto the draft', async () => { + const decorated = { + ...DRAFT_VIEW, + _diagnostics: { valid: true, errors: [] }, + _draft: true, + // ADR-0010 protection carriers ride along and MUST survive: the schemas + // declare them, and dropping them would lose provenance on every write. + _provenance: 'package', + _packageId: 'com.test.crm', + }; + const { ds, draftPuts } = makeDS({ draft: decorated, published: new Error('unused') }); + + const merged = await ds.updateView('crm_activity', DRAFT_VIEW.name, { label: 'Renamed' }); + + // CONTROL: the draft limb really ran and really wrote (a published-limb + // run, or no write at all, would satisfy every absence check below). + expect(draftPuts).toHaveLength(1); + const [url, body] = draftPuts[0]!; + expect(url).toContain('mode=draft'); + expect(body.label).toBe('Renamed'); + + // The decorations are not on the wire… + expect(body).not.toHaveProperty('_diagnostics'); + expect(body).not.toHaveProperty('_draft'); + // …nor in what the caller is handed back to render. + expect(merged).not.toHaveProperty('_diagnostics'); + expect(merged).not.toHaveProperty('_draft'); + + // …and the protection envelope was NOT collateral damage. + expect(body._provenance).toBe('package'); + expect(body._packageId).toBe('com.test.crm'); + }); });