Skip to content

Commit df4fcd4

Browse files
committed
fix(metadata-protocol): the save door refuses a view container saved under a name its own expansion produces
A container such as { name: 'crm_lead.default', object: 'crm_lead', list } saved as crm_lead.default is the stored row of a name its own bare list expands to. Both read doors give a name with a row of its own that row and never let an expansion fill it, and the object door never enumerates a container, so no door answered a view item for the name. The save door now refuses the shape with VALIDATION_ERROR / 400 and the prescription: save the container under its object's name, or save a view item under the expanded name. The predicate is the readers' own expansion (expandRuntimeViewContainer), so every member kind and the expander's de-duplication are judged as the readers place them. The read doors are unchanged. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
1 parent 37442d4 commit df4fcd4

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17683,6 +17683,77 @@ export class ObjectStackProtocolImplementation implements
1768317683
}
1768417684
}
1768517685

17686+
/**
17687+
* [#21558] The save door's refusal of a view container saved under a name
17688+
* its OWN expansion produces — `{ name: 'crm_lead.default', object:
17689+
* 'crm_lead', list }` saved as `crm_lead.default`, whose bare `list`
17690+
* expands to exactly that name.
17691+
*
17692+
* Both read doors give a name with a stored row of its own that row, and
17693+
* let an expansion fill only a name with no row (#21510's one predicate,
17694+
* `namesWithOwnStoredRow`). Such a container IS the row of that name, so
17695+
* its own expansion never fills it: the object door, which never
17696+
* enumerates a container, lists nothing under the name, and the by-name
17697+
* read answers the raw container. No door answers a view item for it, and
17698+
* nothing told the author why. Triage's ruling refuses the shape here, at
17699+
* authoring (Prime Directive 12), and keeps the readers' one predicate
17700+
* whole: ⛔ no second own-row test in the readers.
17701+
*
17702+
* "A name its own expansion produces" is answered by the readers' own
17703+
* expansion, {@link expandRuntimeViewContainer}, never by a copy of its
17704+
* naming, so the save door and the read doors cannot disagree about it:
17705+
* every member kind and the expander's de-duplication are covered as the
17706+
* readers place them. A container on another package's object expands
17707+
* under its own name (#21334), as `<object>.<container name>…`, which is
17708+
* never the container name itself, so that arm is never refused. The
17709+
* package binding is the request's, as the registry write-through
17710+
* registers the expansion.
17711+
*
17712+
* The body judged is the one the author sent, with the door's own `name`
17713+
* stamp ({@link normalizeViewMetadata}: a missing or falsy `name` becomes
17714+
* the save name) applied first, since the expansion of an unnamed
17715+
* container is placed by that name. It is asked BEFORE that function's
17716+
* identity patch: a container whose only member is `form` is not one of
17717+
* the shapes the patch leaves alone, so under the name of a registered
17718+
* view item it would take that item's `viewKind`, stop being a container,
17719+
* and reach the schema as a malformed view item instead of this refusal.
17720+
*
17721+
* A view item (`viewKind` set) is not a container, so a view item saved
17722+
* under an expanded name is untouched: it is the sanctioned override for
17723+
* that name. Rows already stored in this shape are untouched too: the
17724+
* read doors serve them as before, and only a new save is refused.
17725+
*
17726+
* `VALIDATION_ERROR` / 400, the envelope of the name check it sits beside
17727+
* (`savedItemNameRefusal`): an authoring refusal of the request's own
17728+
* name, decided from the body. The prescription is the ruling's: save the
17729+
* container under its object's name, or save a view item under the
17730+
* expanded name. Runtime words carry no tracker number.
17731+
*/
17732+
private containerOwnExpansionNameRefusal(
17733+
type: string,
17734+
item: unknown,
17735+
saveName: string,
17736+
packageId: string | null | undefined,
17737+
): (Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined {
17738+
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
17739+
const body = item as Record<string, unknown>;
17740+
const stamped = body.name ? body : { ...body, name: saveName };
17741+
const own = this.expandRuntimeViewContainer(type, stamped, { packageId })
17742+
.find((expanded) => expanded.name === saveName);
17743+
if (!own) return undefined;
17744+
const object = String(own.object);
17745+
const err = new Error(
17746+
`Invalid view container: it is saved under '${saveName}', which is a name its own expansion `
17747+
+ `produces (its ${String(own.viewKind)} view on '${object}'). An expanded view fills only a name `
17748+
+ `that has no stored row of its own, and this container would be that row, so no read would answer `
17749+
+ `a view under '${saveName}'. Save the container under its object's name, '${object}', or save a `
17750+
+ `view item (name, object, viewKind and config) under '${saveName}'.`,
17751+
) as Error & { code: 'VALIDATION_ERROR'; status: 400 };
17752+
err.code = 'VALIDATION_ERROR';
17753+
err.status = 400;
17754+
return err;
17755+
}
17756+
1768617757
// [#21207] `parentVersion` is a CALLER's version token — the keyed form a
1768717758
// receipt served — and is compared in that form (`storedParentForToken`).
1768817759
// `storedParentVersion` is the in-process twin for a caller that read the
@@ -18164,6 +18235,16 @@ export class ObjectStackProtocolImplementation implements
1816418235
const nameRefusal = savedItemNameRefusal(singularType, request.item, request.name, 'save');
1816518236
if (nameRefusal) throw nameRefusal;
1816618237
}
18238+
// [#21558] …and a view container saved under a name its OWN
18239+
// expansion produces, with the same envelope. Asked of the body as
18240+
// authored, before the stamp below can take a registry entry's
18241+
// `viewKind` onto it. See {@link containerOwnExpansionNameRefusal}.
18242+
{
18243+
const ownExpansionRefusal = this.containerOwnExpansionNameRefusal(
18244+
singularType, request.item, request.name, request.packageId,
18245+
);
18246+
if (ownExpansionRefusal) throw ownExpansionRefusal;
18247+
}
1816718248
let baseline: unknown;
1816818249
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'
1816918250
&& typeof this.engine.registry?.getItem === 'function') {

‎packages/metadata-protocol/src/view-container-runtime-expansion.test.ts‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,163 @@ describe('#21334 a container on another package\'s object never takes that packa
11851185
expect(answers[1], 'the unscoped kernel answers as env_local does').toEqual(answers[0]);
11861186
});
11871187
});
1188+
1189+
/**
1190+
* #21558 — the save door refuses a view container saved under a name its
1191+
* own expansion produces.
1192+
*
1193+
* Measured on `origin/main` before this change, with this harness: the
1194+
* card's save, `{ name: 'showcase_task.default', object: 'showcase_task',
1195+
* list }` under `showcase_task.default`, was accepted on both kernels; the
1196+
* object door then listed nothing under that name (the container is the
1197+
* name's own row, so #21510's predicate keeps its expansion out, and a
1198+
* container is never enumerated) while the by-name read answered the raw
1199+
* container — no door answered a view item for the name. Triage's ruling:
1200+
* refuse it at the write door with a named error and a prescription (save
1201+
* the container under its object's name, or a view item under the
1202+
* expanded name); ⛔ no second own-row test in the readers.
1203+
*
1204+
* The refusal asks the readers' own expansion, so every member kind is
1205+
* covered as the expander places it. The two controls are the ruling's: a
1206+
* container under its object's name saves and expands as before, and a
1207+
* view item under an expanded name saves, as the sanctioned override.
1208+
*/
1209+
describe('#21558 the save door refuses a view container saved under a name its own expansion produces', () => {
1210+
const withoutDiagnostics = (item: any) => {
1211+
if (!item || typeof item !== 'object') return item;
1212+
const { _diagnostics: _drop, ...rest } = item;
1213+
return rest;
1214+
};
1215+
const saveIn = (
1216+
protocol: Protocol, name: string, item: unknown, organizationId?: string, mode?: 'draft' | 'publish',
1217+
) => protocol.saveMetaItem({ type: 'view', name, item, ...scoped(organizationId), ...(mode ? { mode } : {}) } as any);
1218+
const refusalOf = (write: Promise<unknown>) => write.then(() => null, (e: any) => e);
1219+
/** The minimum a rejection pin asserts — the ADR-0112 envelope — plus the subjects it names. */
1220+
const expectRefused = (error: any, saveName: string) => {
1221+
expect(error).toBeInstanceOf(Error);
1222+
expect({ code: error?.code, status: error?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 });
1223+
expect(error.message, 'the refusal names the save name').toContain(`'${saveName}'`);
1224+
expect(error.message, 'the prescription names the object\'s own name').toContain(`'${TASK}'`);
1225+
};
1226+
/** The doors answer `name` with the one item `expectItem` names, and the same item on both. */
1227+
const expectBothDoors = async (
1228+
protocol: Protocol, name: string, organizationId: string | undefined, expectItem: (v: any) => void,
1229+
) => {
1230+
const listed = named(await objectDoor(protocol, organizationId), name);
1231+
expect(listed, `exactly one item answers ${name} on the object door`).toHaveLength(1);
1232+
expectItem(listed[0]);
1233+
const read = await byNameDoor(protocol, name, organizationId);
1234+
expectItem(read);
1235+
expect(withoutDiagnostics(read), `${name}: the by-name read answers the item the object door lists`)
1236+
.toEqual(withoutDiagnostics(listed[0]));
1237+
};
1238+
1239+
for (const [kernel, environmentId] of KERNELS) {
1240+
describe(`on ${kernel}`, () => {
1241+
for (const organizationId of [undefined, ORG]) {
1242+
const scope = organizationId ? 'organization-scoped' : 'environment-wide';
1243+
for (const [kind, m] of Object.entries(MEMBER_CASES)) {
1244+
it(`${scope}, member ${kind}: a container saved under ${m.shadows}, a name its own expansion produces, is refused VALIDATION_ERROR / 400; nothing is stored or registered`, async () => {
1245+
const { protocol, rows, registry } = showcaseHarness(environmentId);
1246+
const body = { name: m.shadows, object: TASK, ...m.member };
1247+
expect(expandViewContainer(TASK, body).map((vi) => vi.name), 'the save name is its own expansion\'s')
1248+
.toEqual([m.shadows]);
1249+
1250+
expectRefused(await refusalOf(saveIn(protocol, m.shadows, body, organizationId)), m.shadows);
1251+
expect([...rows.values()].filter((r) => r.type === 'view'), 'no row is stored').toEqual([]);
1252+
expect(
1253+
registry.listItems('view').filter((it) => isAggregatedViewContainer(it) && it.name === m.shadows),
1254+
'no container is registered under the name',
1255+
).toEqual([]);
1256+
// The doors answer exactly what they answered before the save.
1257+
await expectEveryPackagedNameIntact(protocol, organizationId);
1258+
});
1259+
}
1260+
1261+
it(`${scope}: the card's save is refused as a draft too, and no draft is stored`, async () => {
1262+
const { protocol, rows } = showcaseHarness(environmentId);
1263+
const body = { name: DEFAULT, object: TASK, list: { label: 'SelfNamed', type: 'grid', columns: [{ field: 'title' }] } };
1264+
expectRefused(await refusalOf(saveIn(protocol, DEFAULT, body, organizationId, 'draft')), DEFAULT);
1265+
expect([...rows.values()].filter((r) => r.type === 'view'), 'no draft row is stored').toEqual([]);
1266+
});
1267+
1268+
it(`${scope}: a container saved under its object's name saves and expands as before`, async () => {
1269+
const { protocol } = showcaseHarness(environmentId);
1270+
const overlay = {
1271+
name: TASK,
1272+
list: { label: 'Overlay', type: 'grid', data, columns: [{ field: 'title' }] },
1273+
listViews: { in_progress: { label: 'Overlay In Progress', type: 'grid', data, columns: [{ field: 'title' }] } },
1274+
};
1275+
const saved = (await saveIn(protocol, TASK, overlay, organizationId)) as any;
1276+
expect(saved?.success).toBe(true);
1277+
await expectBothDoors(protocol, DEFAULT, organizationId, (v) => expect(v?.label).toBe('Overlay'));
1278+
await expectBothDoors(protocol, `${TASK}.in_progress`, organizationId, (v) => expect(v?.label).toBe('Overlay In Progress'));
1279+
});
1280+
1281+
it(`${scope}: a view item saved under an expanded name saves, as that name's sanctioned override`, async () => {
1282+
const { protocol } = showcaseHarness(environmentId);
1283+
const item = {
1284+
name: DEFAULT, object: TASK, viewKind: 'list', label: 'ByNameRow',
1285+
config: { type: 'grid', data, columns: [{ field: 'title' }, { field: 'status' }] },
1286+
};
1287+
const saved = (await saveIn(protocol, DEFAULT, item, organizationId)) as any;
1288+
expect(saved?.success).toBe(true);
1289+
await expectBothDoors(protocol, DEFAULT, organizationId, (v) => expect(v?.label).toBe('ByNameRow'));
1290+
});
1291+
}
1292+
});
1293+
}
1294+
});
1295+
});
1296+
1297+
/**
1298+
* #21558 on an object no code package ships: the refusal follows the readers'
1299+
* expansion, the expander's de-duplication included, so a container saved
1300+
* under the de-duplicated name its own expansion gave a member is refused
1301+
* too, and a container under its object's name still saves.
1302+
*/
1303+
describe('#21558 a container under its own expanded name, on a runtime-authored object', () => {
1304+
async function saveCrm(name: string, item: unknown) {
1305+
const harness = makeStubEngine();
1306+
const protocol = new ObjectStackProtocolImplementation(harness.engine);
1307+
const error = await protocol.saveMetaItem({ type: 'view', name, item }).then(() => null, (e: any) => e);
1308+
const viewRows = Array.from(harness.rows.values()).filter((r) => r.type === 'view');
1309+
return { ...harness, protocol, error, viewRows };
1310+
}
1311+
const list = { label: 'All Leads', type: 'grid', columns: [{ field: 'name' }] };
1312+
const other = { label: 'Other', type: 'grid', columns: [{ field: 'company' }] };
1313+
1314+
for (const [saveName, member] of [
1315+
['crm_lead.default', { list }],
1316+
['crm_lead.pipeline', { listViews: { pipeline: list } }],
1317+
// `listViews.default` takes `crm_lead.default` first, so the expander
1318+
// renames the bare `list` to `crm_lead.default_2`.
1319+
['crm_lead.default_2', { listViews: { default: list }, list: other }],
1320+
] as const) {
1321+
it(`saved under ${saveName}: refused VALIDATION_ERROR / 400, nothing stored or registered`, async () => {
1322+
const body = { name: saveName, object: 'crm_lead', ...member };
1323+
expect(expandViewContainer('crm_lead', body).map((vi) => vi.name), 'the save name is its own expansion\'s')
1324+
.toContain(saveName);
1325+
const { error, viewRows, registered } = await saveCrm(saveName, body);
1326+
expect(error).toBeInstanceOf(Error);
1327+
expect({ code: error?.code, status: error?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 });
1328+
expect(viewRows).toEqual([]);
1329+
expect(registered.get('view')?.size ?? 0).toBe(0);
1330+
});
1331+
}
1332+
1333+
it('a container with no `name` is judged under the name the door stamps on it: refused under crm_lead.default', async () => {
1334+
const { error, viewRows } = await saveCrm('crm_lead.default', { object: 'crm_lead', list });
1335+
expect({ code: error?.code, status: error?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 });
1336+
expect(viewRows).toEqual([]);
1337+
});
1338+
1339+
it('CONTROL: the same container under its object\'s name saves, and its expansion fills crm_lead.default', async () => {
1340+
const { error, protocol } = await saveCrm('crm_lead', { name: 'crm_lead', object: 'crm_lead', list });
1341+
expect(error).toBeNull();
1342+
const listed: any = await protocol.getMetaItems({ type: 'view' });
1343+
expect(switcherMatches(listed.items, 'crm_lead').map((v: any) => [v.name, v.label])).toEqual([['crm_lead.default', 'All Leads']]);
1344+
});
11881345
});
11891346

11901347
/**

0 commit comments

Comments
 (0)