Skip to content

Commit af8aa7e

Browse files
committed
spec: inline view arms of the runtime write door require the object binding (#7741)
Direction B per the maintainer ruling of 2026-08-12: the two flattened overlay members of ViewMetadataSchema now require object + viewKind — the exact pair the object-bound read paths filter on (GET /meta/view?object= in rest-server.ts and getViewsByObject() in metadata-manager.ts both match v.viewKind && v.object === obj) — so an inline config that could never be served is refused at the door, draft and active alike, with located guidance that reuses defineView's existing wrap prescription instead of forking a second copy. Personalization PUTs are unaffected: normalizeViewMetadata inherits viewKind/object/label from the shadowed registry entry (#2555) before validation, so a console pin/sort/hide PUT on a real view arrives bound. The body this refuses is the baseline-less one — the dead row QA run #7695 measured being stored and badged valid:true. Union membership (#6391) is preserved: four arms, same order, same JSON-Schema anyOf face; the arms' required set is the only move. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123k4cam2jEAkPmbJeoaY3r
1 parent 7dc1067 commit af8aa7e

8 files changed

Lines changed: 488 additions & 56 deletions

‎packages/spec/src/kernel/metadata-type-schemas.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial<Record<MetadataType, z.ZodType>> =
9999
// standalone ViewItem record, flattened personalization overlay). The bare
100100
// container `ViewSchema` strip-parsed ViewItem/personalization bodies to `{}`,
101101
// making save-time 422 validation and read-time diagnostics a no-op for them.
102+
// [#7741] The flattened overlay arms REQUIRE the `object` + `viewKind`
103+
// binding (ruled 2026-08-12, draft and active alike): an inline config that
104+
// cannot say which object it attaches to would be stored, badged valid, and
105+
// served by no read path. Every consumer of this entry — saveMetaItem's 422
106+
// gate and the read-time diagnostics badge — inherits that refusal here.
102107
view: ViewMetadataSchema,
103108
page: PageSchema,
104109
dashboard: DashboardSchema,

‎packages/spec/src/ui/view-authoring-wire-split.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,20 @@ describe('#5074 — the wire door accepts what the platform itself writes', () =
278278
// `updateView` PUTs `{ ...current, ...partial }`; when there is no stored
279279
// item to merge, `current` is empty and the body is the bare partial.
280280
// `isPinned`/`sortOrder` are declared on member 1, so they are vocabulary.
281-
accept(ViewMetadataSchema, { isPinned: true });
282-
accept(ViewMetadataSchema, { sortOrder: 3 });
281+
//
282+
// [#7741] "Reaches the union" is still the claim — but the UNION now
283+
// refuses the baseline-less bare partial (no `object`/`viewKind` to inherit
284+
// means the saved row could never be served), so the pin is split in two:
285+
// the refusal must be the members' located binding guidance, never the
286+
// precondition's "not a view"; and the same partial as the write path
287+
// actually delivers it (identity inherited from the shadowed entry,
288+
// #2555) parses clean.
289+
for (const partial of [{ isPinned: true }, { sortOrder: 3 }]) {
290+
const msg = reject(ViewMetadataSchema, partial);
291+
expect(msg).not.toContain('Not a `view` body');
292+
expect(msg).toContain('names no `object`');
293+
accept(ViewMetadataSchema, { ...partial, object: 'showcase_task', viewKind: 'list' });
294+
}
283295
});
284296

285297
it('#5599 — …but a body speaking NO view key is stopped before any member runs', () => {
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#7741] The runtime write door refuses an inline view config that carries no
5+
* object binding — maintainer ruling 2026-08-12, direction B.
6+
*
7+
* ## What QA run #7695 measured
8+
*
9+
* `PUT /api/v1/meta/view/<name>` with `{ name, type: 'grid', columns: […],
10+
* data: {…} }` — a single view's config written where the container belongs —
11+
* returned 200 in BOTH draft and active mode, published clean, and read back
12+
* with `_diagnostics: { valid: true }`. Meanwhile `expandViewContainer(...)`
13+
* returned `[]` and `GET /meta/view?object=…` omitted the row: registered,
14+
* reported valid, renders nothing. The same body handed to `defineView()`
15+
* throws the located "Wrap it: `defineView({ list: { … } })`" guidance — the
16+
* two doors disagreed in front of the same author.
17+
*
18+
* ## The ruled fix, and why the binding is a PAIR
19+
*
20+
* The inline (flattened overlay) arms of `ViewMetadataSchema` now REQUIRE
21+
* `object` + `viewKind`. Both, because that pair is what the object-bound read
22+
* paths actually filter on — measured, not assumed:
23+
* `packages/rest/src/rest-server.ts` (`GET /meta/view?object=` →
24+
* `v.viewKind && v.object === obj`) and
25+
* `packages/metadata/src/metadata-manager.ts` (`getViewsByObject()`, same
26+
* predicate). Requiring `object` alone would refuse the card's repro and then
27+
* instruct the author into a SECOND dead row — bound by `object`, still
28+
* invisible to the switcher for want of `viewKind`.
29+
*
30+
* ## Draft and active alike
31+
*
32+
* The ruling: 「draft 与 active 同样适用 …… 现在没有这个证据,不预留」. The pin
33+
* lives at the schema layer on purpose: `getMetadataTypeSchema('view')` is the
34+
* single entry `saveMetaItem` validates against for BOTH `mode: 'draft'` and
35+
* `mode: 'publish'` saves (ADR-0005 §Validation), so there is no draft-shaped
36+
* side door for the schema to miss. Whether some transport skips validation
37+
* entirely is a transport question a spec test cannot see.
38+
*
39+
* ## Reverse verification — direction decided before it was run
40+
*
41+
* Expected direction: restoring the two fields to `.optional()` turns the
42+
* card's repro body GREEN again through this same door (acceptance direction,
43+
* the plain before/after) while `defineView` keeps throwing — re-opening
44+
* exactly the two-door disagreement above. Observed on this branch (temporary
45+
* `git checkout origin/main -- src/ui/view.zod.ts` after committing the fix):
46+
* the repro parses `success: true` on the old schema, `success: false` on the
47+
* new one; no inverted or count-shaped surprises.
48+
*/
49+
50+
import { describe, it, expect } from 'vitest';
51+
import { z } from 'zod';
52+
import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas';
53+
import { ViewMetadataSchema, diagnoseViewMetadata, defineView } from './view.zod';
54+
55+
/** The card's exact repro body (#7741 / QA run #7695), verbatim shape. */
56+
const REPRO = {
57+
name: 'qa_dead_view',
58+
type: 'grid',
59+
columns: ['title', 'status'],
60+
data: { provider: 'object', object: 'showcase_task' },
61+
};
62+
63+
/** The same inline config, properly bound. */
64+
const BOUND = { ...REPRO, object: 'showcase_task', viewKind: 'list' };
65+
66+
const door = () => getMetadataTypeSchema('view')!;
67+
68+
function refusalText(body: unknown): string {
69+
const r = door().safeParse(body);
70+
expect(r.success, `expected the door to REFUSE ${JSON.stringify(body)}`).toBe(false);
71+
return JSON.stringify((r as { error?: { issues?: unknown } }).error?.issues ?? []);
72+
}
73+
74+
describe('[#7741] the runtime write door refuses the unbound inline view config', () => {
75+
it('REFUSES the card\'s exact repro body through getMetadataTypeSchema(\'view\')', () => {
76+
const r = door().safeParse(REPRO);
77+
expect(r.success).toBe(false);
78+
});
79+
80+
it('the refusal is LOCATED: the claimed inline arm carries it at `object` / `viewKind`', () => {
81+
const r = door().safeParse(REPRO);
82+
expect(r.success).toBe(false);
83+
if (r.success) return;
84+
const root = r.error.issues[0] as unknown as {
85+
code: string;
86+
errors: Array<Array<{ path: PropertyKey[]; message: string }>>;
87+
};
88+
// The union envelope other consumers key on (ADR-0112 at the issue level).
89+
expect(root.code).toBe('invalid_union');
90+
expect(root.errors).toHaveLength(4);
91+
// Branch 2 is the listOverlay the body claims (`type: 'grid'`); #7510's
92+
// focusing mutes the other three, so the author reads ONLY the binding
93+
// prescription, at the paths of the keys to add.
94+
const claimed = root.errors[2]!;
95+
expect(claimed.map((i) => i.path)).toEqual([['object'], ['viewKind']]);
96+
for (const index of [0, 1, 3]) {
97+
expect(root.errors[index]).toHaveLength(1);
98+
expect(root.errors[index]![0]!.message).toContain('this body reads as `listOverlay`');
99+
}
100+
});
101+
102+
it('…naming the offending shape and both halves of the remedy (bind, record, or wrap)', () => {
103+
const text = refusalText(REPRO);
104+
// The offending shape, by name.
105+
expect(text).toContain('inline view config');
106+
// The binding remedy, with the measured reason a binding is required.
107+
expect(text).toContain('names no `object`');
108+
expect(text).toContain('GET /meta/view?object=');
109+
expect(text).toContain('add `object:');
110+
// The record alternative.
111+
expect(text).toContain('{ name, object, viewKind, config: { … } }');
112+
// The wrap remedy — the SAME prose family `defineView` throws for this
113+
// body (the ruling: the write door reuses the build path's guidance).
114+
expect(text).toContain('Wrap it: `defineView({ list: { type, data, columns, … } })`');
115+
expect(text).toContain('defineView({ listViews: { my_view: { … } } })');
116+
});
117+
118+
it('the guidance is the same prose family the build door throws for the repro', () => {
119+
// The contrast the card measured in step 5 of its reproduction: same body,
120+
// `defineView` door. Both doors now speak the wrap remedy.
121+
expect(() => defineView(REPRO as never)).toThrow(/defineView\(\{ list: \{ type, data, columns/);
122+
});
123+
124+
it('a HALF binding is refused too — `object` alone or `viewKind` alone', () => {
125+
// `object` without `viewKind` is precisely the second dead row the pair
126+
// requirement exists to prevent (invisible to the switcher's
127+
// `v.viewKind && v.object === obj` filter).
128+
expect(refusalText({ ...REPRO, object: 'showcase_task' })).toContain('names no `viewKind`');
129+
expect(refusalText({ ...REPRO, viewKind: 'list' })).toContain('names no `object`');
130+
});
131+
132+
it('the form-family inline arm refuses the same way', () => {
133+
const text = refusalText({ name: 'qa_dead_form', type: 'wizard' });
134+
expect(text).toContain('names no `object`');
135+
});
136+
137+
it('diagnoseViewMetadata names the inline branch, so consumers render the binding guidance', () => {
138+
const d = diagnoseViewMetadata(REPRO);
139+
expect(d.success).toBe(false);
140+
if (d.success) return;
141+
expect(d.branch).toBe('listOverlay');
142+
expect(d.issues.map((i) => i.path)).toEqual([['object'], ['viewKind']]);
143+
});
144+
});
145+
146+
describe('[#7741] what the door still accepts, byte for byte', () => {
147+
it('ACCEPTS the properly bound inline body — and adds nothing to it', () => {
148+
const r = door().safeParse(BOUND);
149+
expect(r.success, JSON.stringify((r as { error?: { issues?: unknown } }).error?.issues)).toBe(true);
150+
if (!r.success) return;
151+
expect(r.data).toEqual(BOUND);
152+
});
153+
154+
it('ACCEPTS the ViewItem record arm byte-identically', () => {
155+
const record = {
156+
name: 'showcase_task.all',
157+
object: 'showcase_task',
158+
viewKind: 'list',
159+
config: { type: 'grid', columns: ['title'], data: { provider: 'object', object: 'showcase_task' } },
160+
};
161+
const r = door().safeParse(record);
162+
expect(r.success).toBe(true);
163+
if (!r.success) return;
164+
expect(r.data).toEqual(record);
165+
});
166+
167+
it('ACCEPTS the container arm byte-identically — #6391\'s union membership is intact', () => {
168+
const container = {
169+
object: 'showcase_task',
170+
list: { type: 'grid', columns: ['title'], data: { provider: 'object', object: 'showcase_task' } },
171+
};
172+
const r = door().safeParse(container);
173+
expect(r.success).toBe(true);
174+
if (!r.success) return;
175+
expect(r.data).toEqual(container);
176+
});
177+
178+
it('ACCEPTS the post-normalize personalization PUT (identity inherited from the shadowed entry)', () => {
179+
// What `normalizeViewMetadata` + `viewIdentityPatch` (#2555) actually hand
180+
// this schema for a console column-sort PUT on a real view.
181+
const r = door().safeParse({
182+
type: 'grid',
183+
data: { provider: 'object', object: 'showcase_task' },
184+
columns: ['title'],
185+
sort: [{ field: 'estimate_hours', order: 'desc' }],
186+
name: 'showcase_task.default',
187+
viewKind: 'list',
188+
object: 'showcase_task',
189+
label: 'All Tasks',
190+
});
191+
expect(r.success).toBe(true);
192+
});
193+
});
194+
195+
describe('[#7741] the emitted contract declares what it enforces', () => {
196+
it('the inline arms\' JSON Schema marks `object` and `viewKind` required, in both io directions', () => {
197+
// Studio's SchemaForm is generated from this — declared = enforced means
198+
// an AI author is TOLD the binding is required before the 422 says so.
199+
for (const io of ['output', 'input'] as const) {
200+
const json = z.toJSONSchema(door(), { unrepresentable: 'any', io }) as {
201+
anyOf?: Array<{ required?: string[] }>;
202+
};
203+
expect(json.anyOf).toHaveLength(4);
204+
for (const member of [json.anyOf![2]!, json.anyOf![3]!]) {
205+
expect(member.required ?? []).toEqual(expect.arrayContaining(['object', 'viewKind']));
206+
}
207+
}
208+
});
209+
210+
it('ViewMetadataSchema and the kernel registry entry are one schema — both modes, one verdict', () => {
211+
// `saveMetaItem` resolves BOTH draft and publish saves through
212+
// `getMetadataTypeSchema('view')`; pinning the identity here is the
213+
// schema-level half of "draft 与 active 同样适用".
214+
expect(door()).toBe(ViewMetadataSchema);
215+
expect(ViewMetadataSchema.safeParse(REPRO).success).toBe(false);
216+
});
217+
});

0 commit comments

Comments
 (0)