Skip to content

Commit 3245174

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): read decorations stop round-tripping into persisted metadata bodies (#4326) (#4334)
`getMetaItem`/`getMetaItems` decorate every served document with `_diagnostics` (and `_draft` on preview reads), while the write path persists the request body verbatim by design (ADR-0005 §Validation). Nothing stripped the decorations in between, so the standard designer round-trip — GET the served document, edit a field, PUT the whole body back — baked a stale read-time verdict into sys_metadata.metadata, its checksum, and every history diff. Never user-visible (reads recompute and the fresh verdict shadows the stale one), which is why it needed a pin on the stored bytes rather than a behaviour test: verified by disabling the strip and watching all three round-trip tests go red. `saveMetaItem` now strips the decorations before the destructive-change diff, the schema gate, the authoring gate and persistence. A silent strip, unlike the neighbouring layered-envelope rejection: these are our own decorations riding on a document that is otherwise exactly what the author edited, so rejecting the round-trip would be hostile. The ADR-0010 protection envelope (`_lock`/`_lockReason`/`_provenance`) and `_packageId` are deliberately left alone — envelope state the write path legitimately carries. Also closes out the #3903 residual verification: every `SysMetadataRepository.get` caller was audited and the verbatim boundary documented on the method — three sites read only `hash` (parent-version lineage, existence probes; converting would break the body↔hash pairing), `diffMeta` compares against equally-verbatim history rows (converting one side would render the conversion as a user edit), and the seed-publish path is the one structurally-serving caller — vacuous today since no conversion targets the seed surface, named as the seam to wire if one lands. Closes #4326. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e87fea1 commit 3245174

5 files changed

Lines changed: 319 additions & 1 deletion

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): read decorations stop round-tripping into persisted metadata bodies (#4326)
6+
7+
`getMetaItem` / `getMetaItems` decorate every served document with
8+
`_diagnostics` (and `_draft` on preview reads), while the write path persists
9+
the request body **verbatim** by design (ADR-0005 §Validation — `parsed.data`
10+
would strip Studio-only auxiliary fields). Nothing stripped the decorations in
11+
between, so the standard designer round-trip — GET the served document, edit a
12+
field, PUT the whole body back — baked a stale read-time verdict into
13+
`sys_metadata.metadata`, into its checksum, and into every history diff.
14+
15+
It was never user-visible: reads recompute `_diagnostics` and the fresh verdict
16+
shadows the persisted one. What it corrupted was the stored bytes — a
17+
decoration-only re-save moved the content checksum, and history diffs carried
18+
diagnostic noise no author wrote.
19+
20+
`saveMetaItem` now strips `_diagnostics` and `_draft` from the body before the
21+
destructive-change diff, the schema gate, the authoring gate, and persistence
22+
(new `stripReadDecorations`, exported for tests). A **silent** strip, unlike the
23+
neighbouring layered-envelope rejection: those keys are our own decoration
24+
riding on a document that is otherwise exactly what the author edited, so
25+
rejecting the round-trip would be hostile. The ADR-0010 protection envelope
26+
(`_lock`, `_lockReason`, `_provenance`) and `_packageId` are deliberately left
27+
alone — envelope state the write path legitimately carries, not read decoration.
28+
29+
Also documents the #3903 conversion boundary on `SysMetadataRepository.get`:
30+
its body stays verbatim because every caller wants the bytes a hash was
31+
computed over (parent-version lineage, existence probes) or is diffing against
32+
equally-verbatim history rows — conversion belongs one layer up, at the
33+
protocol's serving seams.

packages/metadata-protocol/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata, graftNormalizedOperators } from './protocol.js';
3+
export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata, graftNormalizedOperators, stripReadDecorations } from './protocol.js';
44
export { createMetadataProtocolPlugin, assembleMetadataProtocol } from './plugin.js';
55
export type { MetadataProtocolPluginOptions } from './plugin.js';
66
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4326 — read decorations must not round-trip into the persisted body.
5+
*
6+
* `getMetaItem`/`getMetaItems` stamp `_diagnostics` on every served document
7+
* (and `_draft` on draft reads), while the write path persists the request body
8+
* VERBATIM by design (ADR-0005 §Validation — `parsed.data` would strip
9+
* Studio-only auxiliary fields). The standard Studio round-trip therefore used
10+
* to bake a read-time verdict into `sys_metadata.metadata`, into its checksum,
11+
* and into every history diff.
12+
*
13+
* Nothing user-visible was ever wrong — reads recompute and the fresh verdict
14+
* shadows the stale one — which is exactly why this needs a pin: the invariant
15+
* being protected is "a GET → PUT round-trip persists a byte-identical body",
16+
* and only the stored bytes can show it.
17+
*/
18+
import { describe, expect, it } from 'vitest';
19+
import { ObjectStackProtocolImplementation, stripReadDecorations } from './index.js';
20+
21+
interface Row {
22+
id: string;
23+
type: string;
24+
name: string;
25+
organization_id: string | null;
26+
package_id: string | null;
27+
state: string;
28+
metadata: string;
29+
checksum?: string;
30+
version?: number;
31+
}
32+
33+
function matches(r: Row, where: Record<string, unknown>): boolean {
34+
for (const [k, v] of Object.entries(where)) {
35+
if (v === undefined) continue;
36+
if ((r as any)[k] !== v) return false;
37+
}
38+
return true;
39+
}
40+
41+
function keyOf(w: Record<string, unknown>) {
42+
return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`;
43+
}
44+
45+
function makeStubEngine() {
46+
const rows = new Map<string, Row>();
47+
let nextId = 0;
48+
const findRow = (w: Record<string, unknown>) => {
49+
for (const [k, r] of rows) if (matches(r, w)) return { key: k, row: r };
50+
return null;
51+
};
52+
const engine: any = {
53+
async findOne(_t: string, opts: { where: Record<string, unknown> }) {
54+
return findRow(opts.where)?.row ?? null;
55+
},
56+
async find(_t: string, opts: { where: Record<string, unknown> }) {
57+
return Array.from(rows.values()).filter((r) => matches(r, opts.where));
58+
},
59+
async insert(table: string, data: Record<string, unknown>) {
60+
if (table !== 'sys_metadata') return { id: 'side_table' };
61+
const row = { id: `r_${++nextId}`, ...(data as any) } as Row;
62+
rows.set(keyOf(data), row);
63+
return { id: row.id };
64+
},
65+
async update(table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
66+
if (table !== 'sys_metadata') return { id: null };
67+
const found = findRow(opts.where);
68+
if (!found) return { id: null };
69+
const merged = { ...found.row, ...(data as any) };
70+
rows.delete(found.key);
71+
rows.set(keyOf(merged), merged);
72+
return { id: found.row.id };
73+
},
74+
async delete() { return { deleted: 0 }; },
75+
async transaction<T>(cb: (ctx: any) => Promise<T>): Promise<T> { return cb(undefined); },
76+
async syncObjectSchema() { /* no DDL in this stub */ },
77+
registry: {
78+
listItems: () => [],
79+
isPackageDisabled: () => false,
80+
getItem: () => undefined,
81+
registerItem: () => {},
82+
registerObject: () => {},
83+
getPackage: () => undefined,
84+
},
85+
};
86+
return { engine, rows };
87+
}
88+
89+
const storedBody = (rows: Map<string, Row>, name: string) => {
90+
const row = Array.from(rows.values()).find((r) => r.name === name)!;
91+
return JSON.parse(row.metadata);
92+
};
93+
94+
const objectBody = (name: string) => ({
95+
name,
96+
label: 'Invoice',
97+
fields: { amount: { type: 'currency', label: 'Amount' } },
98+
});
99+
100+
describe('stripReadDecorations (#4326)', () => {
101+
it('removes the read-only decorations', () => {
102+
const out = stripReadDecorations({
103+
name: 'x', _diagnostics: { valid: true }, _draft: true,
104+
}) as Record<string, unknown>;
105+
expect(out).toEqual({ name: 'x' });
106+
});
107+
108+
it('returns the SAME reference when there is nothing to strip', () => {
109+
const body = { name: 'x', label: 'X' };
110+
expect(stripReadDecorations(body)).toBe(body);
111+
});
112+
113+
it('leaves the ADR-0010 protection envelope and package provenance alone', () => {
114+
// These share the underscore spelling but are envelope state the write
115+
// path legitimately carries — stripping them would loosen a packaged lock.
116+
const body = {
117+
name: 'x',
118+
_lock: 'full',
119+
_lockReason: 'shipped by package',
120+
_provenance: 'package',
121+
_packageId: 'app.crm',
122+
_diagnostics: { valid: false },
123+
};
124+
const out = stripReadDecorations(body) as Record<string, unknown>;
125+
expect(out).toEqual({
126+
name: 'x',
127+
_lock: 'full',
128+
_lockReason: 'shipped by package',
129+
_provenance: 'package',
130+
_packageId: 'app.crm',
131+
});
132+
});
133+
134+
it('passes non-object input through untouched', () => {
135+
expect(stripReadDecorations(null)).toBe(null);
136+
expect(stripReadDecorations('str')).toBe('str');
137+
const arr = [{ _diagnostics: {} }];
138+
expect(stripReadDecorations(arr)).toBe(arr);
139+
});
140+
141+
it('does not mutate the caller input', () => {
142+
const body = { name: 'x', _diagnostics: { valid: true } };
143+
stripReadDecorations(body);
144+
expect(body._diagnostics).toEqual({ valid: true });
145+
});
146+
});
147+
148+
describe('saveMetaItem — the Studio round-trip persists a byte-identical body (#4326)', () => {
149+
it('GET → PUT the whole served document stores no _diagnostics', async () => {
150+
const { engine, rows } = makeStubEngine();
151+
const protocol = new ObjectStackProtocolImplementation(engine);
152+
153+
const authored = objectBody('crm_invoice');
154+
await protocol.saveMetaItem({ type: 'object', name: 'crm_invoice', item: authored });
155+
const firstStored = storedBody(rows, 'crm_invoice');
156+
157+
// What Studio actually holds: the SERVED document, decorations included.
158+
const served: any = (await protocol.getMetaItem({ type: 'object', name: 'crm_invoice' })).item;
159+
expect(served._diagnostics).toBeDefined(); // precondition — the read does decorate
160+
161+
// Edit one label and PUT the whole thing back, as the designer does.
162+
await protocol.saveMetaItem({
163+
type: 'object',
164+
name: 'crm_invoice',
165+
item: { ...served, label: 'Invoice (edited)' },
166+
});
167+
168+
const stored = storedBody(rows, 'crm_invoice');
169+
expect('_diagnostics' in stored).toBe(false);
170+
expect(stored.label).toBe('Invoice (edited)');
171+
// Everything except the edited key is byte-identical to the first save.
172+
expect({ ...stored, label: firstStored.label }).toEqual(firstStored);
173+
});
174+
175+
it('a draft read PUT back does not persist the _draft preview badge', async () => {
176+
const { engine, rows } = makeStubEngine();
177+
const protocol = new ObjectStackProtocolImplementation(engine);
178+
179+
await protocol.saveMetaItem({
180+
type: 'object', name: 'crm_quote', item: objectBody('crm_quote'), mode: 'draft',
181+
});
182+
// The badge comes from the PREVIEW read (`previewDrafts`), which is what
183+
// the designer's "preview pending changes" surface calls — a plain
184+
// `state: 'draft'` read returns the body unbadged.
185+
const draft: any = (await protocol.getMetaItem({
186+
type: 'object', name: 'crm_quote', previewDrafts: true,
187+
})).item;
188+
expect(draft._draft).toBe(true); // precondition — the preview read badges
189+
190+
await protocol.saveMetaItem({
191+
type: 'object', name: 'crm_quote', item: draft, mode: 'draft',
192+
});
193+
194+
const stored = storedBody(rows, 'crm_quote');
195+
expect('_draft' in stored).toBe(false);
196+
expect('_diagnostics' in stored).toBe(false);
197+
// Draft-ness lives in the row's state column, not the body.
198+
expect(Array.from(rows.values()).find((r) => r.name === 'crm_quote')!.state).toBe('draft');
199+
});
200+
201+
it('keeps the checksum stable across a decoration-only round-trip', async () => {
202+
const { engine, rows } = makeStubEngine();
203+
const protocol = new ObjectStackProtocolImplementation(engine);
204+
205+
await protocol.saveMetaItem({ type: 'object', name: 'crm_lead', item: objectBody('crm_lead') });
206+
const before = Array.from(rows.values()).find((r) => r.name === 'crm_lead')!.checksum;
207+
208+
// Re-save the served document unchanged — the only difference is our own
209+
// decoration, so the content hash must not move.
210+
const served: any = (await protocol.getMetaItem({ type: 'object', name: 'crm_lead' })).item;
211+
await protocol.saveMetaItem({ type: 'object', name: 'crm_lead', item: served });
212+
213+
const after = Array.from(rows.values()).find((r) => r.name === 'crm_lead')!.checksum;
214+
expect(after).toBe(before);
215+
});
216+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,50 @@ function describeMalformedFilter(filter: unknown[]): string {
356356
return `${JSON.stringify(filter)} is not a recognised filter shape.`;
357357
}
358358

359+
/**
360+
* Keys the READ path stamps onto a served metadata document, which therefore
361+
* must never survive back into a persisted body (#4326).
362+
*
363+
* Both are recomputed on every read, so persisting them stores a stale copy of
364+
* something the reader already replaces:
365+
* - `_diagnostics` — the spec-validation verdict `decorateMetadataItem`
366+
* spreads onto every `getMetaItem`/`getMetaItems` result. A second producer
367+
* stamps the same key: view-container expansion records a name-collision
368+
* rename warning (`stampRenameWarning`, spec/ui/view.zod.ts). Both are
369+
* derived from the document, so neither belongs inside it;
370+
* - `_draft` — the preview badge added by draft reads. Draft-ness lives in
371+
* the row's `state` column and the `mode` parameter, never in the body.
372+
*
373+
* Deliberately NOT stripped, though they share the underscore spelling: the
374+
* ADR-0010 protection envelope (`_lock`, `_lockReason`, `_provenance`) and
375+
* `_packageId`. Those are envelope state the write path legitimately carries
376+
* and merges (see `mergeArtifactProtection`) — not read-time decoration.
377+
*/
378+
const READ_ONLY_DECORATIONS = ['_diagnostics', '_draft'] as const;
379+
380+
/**
381+
* Remove {@link READ_ONLY_DECORATIONS} from an about-to-persist body.
382+
*
383+
* A **silent** strip, unlike the layered-envelope rejection in `saveMetaItem`:
384+
* that envelope is a wrong document the caller must fix, whereas these keys are
385+
* our own decoration riding along on a document that is otherwise exactly what
386+
* the author edited. Rejecting the standard GET → edit → PUT round-trip would
387+
* be hostile; stripping restores the invariant that a round-trip persists the
388+
* body byte-identical.
389+
*
390+
* Returns the SAME reference when there is nothing to strip, so the common path
391+
* allocates nothing (the discipline {@link graftNormalizedOperators} follows).
392+
* Non-object inputs pass through — the caller's own validation owns those.
393+
*/
394+
export function stripReadDecorations(item: unknown): unknown {
395+
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
396+
const dict = item as Record<string, unknown>;
397+
if (!READ_ONLY_DECORATIONS.some((k) => k in dict)) return item;
398+
const next = { ...dict };
399+
for (const k of READ_ONLY_DECORATIONS) delete next[k];
400+
return next;
401+
}
402+
359403
/**
360404
* Guarantee a `view` body carries a top-level `name`.
361405
*
@@ -5312,6 +5356,15 @@ export class ObjectStackProtocolImplementation implements
53125356
if (!request.item) {
53135357
throw new Error('Item data is required');
53145358
}
5359+
// Drop OUR OWN read decorations before anything reads the body (#4326).
5360+
// The write path persists verbatim by design (ADR-0005 §Validation), so
5361+
// the standard Studio round-trip — GET (decorated) → edit → PUT the whole
5362+
// body — would otherwise bake a read-time verdict into the row, its
5363+
// checksum, and every history diff. See {@link stripReadDecorations} for
5364+
// why this is a silent strip and which underscore keys are NOT touched.
5365+
// Placed first so the destructive-change diff, the schema gate, the
5366+
// authoring gate and the persisted body all see the same document.
5367+
request.item = stripReadDecorations(request.item);
53155368
// Per-item lifecycle (ADR-0005 §"Drafts"). Default is `'publish'`
53165369
// (legacy semantics — save goes straight live) to keep callers
53175370
// that predate the draft/publish split working. Studio's

packages/metadata-protocol/src/sys-metadata-repository.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,22 @@ export class SysMetadataRepository implements MetadataRepository {
249249
* `opts.state` selects which lifecycle row to read: defaults to the
250250
* live published row (`'active'`). Pass `'draft'` to read the pending
251251
* unpublished revision (if any).
252+
*
253+
* **The body is VERBATIM — no ADR-0087 conversion (#3903 boundary).** This
254+
* repository is the version layer, and every caller wants the bytes that
255+
* were written, not today's canonical rendering of them:
256+
* - `saveMetaItem` / `revertCommit` / `deleteMetaItem` read only `hash`
257+
* (parent-version lineage and existence probes) — converting would
258+
* change the body a hash was computed over and break the pairing;
259+
* - `diffMeta` compares this body against `sys_metadata_history` bodies,
260+
* which are verbatim by design. Converting one side only would render
261+
* the conversion itself as a user-authored change in the diff.
262+
* Metadata that is *served to a consumer* is converted one layer up, at the
263+
* `ObjectStackProtocolImplementation` read seams. The lone exception worth
264+
* knowing: the seed body captured for `publishPackageDrafts` flows from here
265+
* into `applySeedBodies`, which IS a serving path — vacuous today because no
266+
* conversion targets the seed/dataset surface, but the seam to wire if one
267+
* ever lands.
252268
*/
253269
async get(
254270
ref: MetaRef,

0 commit comments

Comments
 (0)