From df4fcd463650f7ae63f9a9da38cb7e5b7faea88a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:23:50 +0000 Subject: [PATCH 1/2] 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 --- packages/metadata-protocol/src/protocol.ts | 81 +++++++++ .../view-container-runtime-expansion.test.ts | 157 ++++++++++++++++++ 2 files changed, 238 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 240c2100ed..19953a79f4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -17683,6 +17683,77 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#21558] The save door's refusal of a view container saved under a name + * its OWN expansion produces — `{ name: 'crm_lead.default', object: + * 'crm_lead', list }` saved as `crm_lead.default`, whose bare `list` + * expands to exactly that name. + * + * Both read doors give a name with a stored row of its own that row, and + * let an expansion fill only a name with no row (#21510's one predicate, + * `namesWithOwnStoredRow`). Such a container IS the row of that name, so + * its own expansion never fills it: the object door, which never + * enumerates a container, lists nothing under the name, and the by-name + * read answers the raw container. No door answers a view item for it, and + * nothing told the author why. Triage's ruling refuses the shape here, at + * authoring (Prime Directive 12), and keeps the readers' one predicate + * whole: ⛔ no second own-row test in the readers. + * + * "A name its own expansion produces" is answered by the readers' own + * expansion, {@link expandRuntimeViewContainer}, never by a copy of its + * naming, so the save door and the read doors cannot disagree about it: + * every member kind and the expander's de-duplication are covered as the + * readers place them. A container on another package's object expands + * under its own name (#21334), as `.…`, which is + * never the container name itself, so that arm is never refused. The + * package binding is the request's, as the registry write-through + * registers the expansion. + * + * The body judged is the one the author sent, with the door's own `name` + * stamp ({@link normalizeViewMetadata}: a missing or falsy `name` becomes + * the save name) applied first, since the expansion of an unnamed + * container is placed by that name. It is asked BEFORE that function's + * identity patch: a container whose only member is `form` is not one of + * the shapes the patch leaves alone, so under the name of a registered + * view item it would take that item's `viewKind`, stop being a container, + * and reach the schema as a malformed view item instead of this refusal. + * + * A view item (`viewKind` set) is not a container, so a view item saved + * under an expanded name is untouched: it is the sanctioned override for + * that name. Rows already stored in this shape are untouched too: the + * read doors serve them as before, and only a new save is refused. + * + * `VALIDATION_ERROR` / 400, the envelope of the name check it sits beside + * (`savedItemNameRefusal`): an authoring refusal of the request's own + * name, decided from the body. The prescription is the ruling's: save the + * container under its object's name, or save a view item under the + * expanded name. Runtime words carry no tracker number. + */ + private containerOwnExpansionNameRefusal( + type: string, + item: unknown, + saveName: string, + packageId: string | null | undefined, + ): (Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined { + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + const body = item as Record; + const stamped = body.name ? body : { ...body, name: saveName }; + const own = this.expandRuntimeViewContainer(type, stamped, { packageId }) + .find((expanded) => expanded.name === saveName); + if (!own) return undefined; + const object = String(own.object); + const err = new Error( + `Invalid view container: it is saved under '${saveName}', which is a name its own expansion ` + + `produces (its ${String(own.viewKind)} view on '${object}'). An expanded view fills only a name ` + + `that has no stored row of its own, and this container would be that row, so no read would answer ` + + `a view under '${saveName}'. Save the container under its object's name, '${object}', or save a ` + + `view item (name, object, viewKind and config) under '${saveName}'.`, + ) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + return err; + } + // [#21207] `parentVersion` is a CALLER's version token — the keyed form a // receipt served — and is compared in that form (`storedParentForToken`). // `storedParentVersion` is the in-process twin for a caller that read the @@ -18164,6 +18235,16 @@ export class ObjectStackProtocolImplementation implements const nameRefusal = savedItemNameRefusal(singularType, request.item, request.name, 'save'); if (nameRefusal) throw nameRefusal; } + // [#21558] …and a view container saved under a name its OWN + // expansion produces, with the same envelope. Asked of the body as + // authored, before the stamp below can take a registry entry's + // `viewKind` onto it. See {@link containerOwnExpansionNameRefusal}. + { + const ownExpansionRefusal = this.containerOwnExpansionNameRefusal( + singularType, request.item, request.name, request.packageId, + ); + if (ownExpansionRefusal) throw ownExpansionRefusal; + } let baseline: unknown; if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view' && typeof this.engine.registry?.getItem === 'function') { diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index 6a5cb38c83..9df0f8928c 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -1185,6 +1185,163 @@ describe('#21334 a container on another package\'s object never takes that packa expect(answers[1], 'the unscoped kernel answers as env_local does').toEqual(answers[0]); }); }); + + /** + * #21558 — the save door refuses a view container saved under a name its + * own expansion produces. + * + * Measured on `origin/main` before this change, with this harness: the + * card's save, `{ name: 'showcase_task.default', object: 'showcase_task', + * list }` under `showcase_task.default`, was accepted on both kernels; the + * object door then listed nothing under that name (the container is the + * name's own row, so #21510's predicate keeps its expansion out, and a + * container is never enumerated) while the by-name read answered the raw + * container — no door answered a view item for the name. Triage's ruling: + * refuse it at the write door with a named error and a prescription (save + * the container under its object's name, or a view item under the + * expanded name); ⛔ no second own-row test in the readers. + * + * The refusal asks the readers' own expansion, so every member kind is + * covered as the expander places it. The two controls are the ruling's: a + * container under its object's name saves and expands as before, and a + * view item under an expanded name saves, as the sanctioned override. + */ + describe('#21558 the save door refuses a view container saved under a name its own expansion produces', () => { + const withoutDiagnostics = (item: any) => { + if (!item || typeof item !== 'object') return item; + const { _diagnostics: _drop, ...rest } = item; + return rest; + }; + const saveIn = ( + protocol: Protocol, name: string, item: unknown, organizationId?: string, mode?: 'draft' | 'publish', + ) => protocol.saveMetaItem({ type: 'view', name, item, ...scoped(organizationId), ...(mode ? { mode } : {}) } as any); + const refusalOf = (write: Promise) => write.then(() => null, (e: any) => e); + /** The minimum a rejection pin asserts — the ADR-0112 envelope — plus the subjects it names. */ + const expectRefused = (error: any, saveName: string) => { + expect(error).toBeInstanceOf(Error); + expect({ code: error?.code, status: error?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + expect(error.message, 'the refusal names the save name').toContain(`'${saveName}'`); + expect(error.message, 'the prescription names the object\'s own name').toContain(`'${TASK}'`); + }; + /** The doors answer `name` with the one item `expectItem` names, and the same item on both. */ + const expectBothDoors = async ( + protocol: Protocol, name: string, organizationId: string | undefined, expectItem: (v: any) => void, + ) => { + const listed = named(await objectDoor(protocol, organizationId), name); + expect(listed, `exactly one item answers ${name} on the object door`).toHaveLength(1); + expectItem(listed[0]); + const read = await byNameDoor(protocol, name, organizationId); + expectItem(read); + expect(withoutDiagnostics(read), `${name}: the by-name read answers the item the object door lists`) + .toEqual(withoutDiagnostics(listed[0])); + }; + + for (const [kernel, environmentId] of KERNELS) { + describe(`on ${kernel}`, () => { + for (const organizationId of [undefined, ORG]) { + const scope = organizationId ? 'organization-scoped' : 'environment-wide'; + for (const [kind, m] of Object.entries(MEMBER_CASES)) { + 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 () => { + const { protocol, rows, registry } = showcaseHarness(environmentId); + const body = { name: m.shadows, object: TASK, ...m.member }; + expect(expandViewContainer(TASK, body).map((vi) => vi.name), 'the save name is its own expansion\'s') + .toEqual([m.shadows]); + + expectRefused(await refusalOf(saveIn(protocol, m.shadows, body, organizationId)), m.shadows); + expect([...rows.values()].filter((r) => r.type === 'view'), 'no row is stored').toEqual([]); + expect( + registry.listItems('view').filter((it) => isAggregatedViewContainer(it) && it.name === m.shadows), + 'no container is registered under the name', + ).toEqual([]); + // The doors answer exactly what they answered before the save. + await expectEveryPackagedNameIntact(protocol, organizationId); + }); + } + + it(`${scope}: the card's save is refused as a draft too, and no draft is stored`, async () => { + const { protocol, rows } = showcaseHarness(environmentId); + const body = { name: DEFAULT, object: TASK, list: { label: 'SelfNamed', type: 'grid', columns: [{ field: 'title' }] } }; + expectRefused(await refusalOf(saveIn(protocol, DEFAULT, body, organizationId, 'draft')), DEFAULT); + expect([...rows.values()].filter((r) => r.type === 'view'), 'no draft row is stored').toEqual([]); + }); + + it(`${scope}: a container saved under its object's name saves and expands as before`, async () => { + const { protocol } = showcaseHarness(environmentId); + const overlay = { + name: TASK, + list: { label: 'Overlay', type: 'grid', data, columns: [{ field: 'title' }] }, + listViews: { in_progress: { label: 'Overlay In Progress', type: 'grid', data, columns: [{ field: 'title' }] } }, + }; + const saved = (await saveIn(protocol, TASK, overlay, organizationId)) as any; + expect(saved?.success).toBe(true); + await expectBothDoors(protocol, DEFAULT, organizationId, (v) => expect(v?.label).toBe('Overlay')); + await expectBothDoors(protocol, `${TASK}.in_progress`, organizationId, (v) => expect(v?.label).toBe('Overlay In Progress')); + }); + + it(`${scope}: a view item saved under an expanded name saves, as that name's sanctioned override`, async () => { + const { protocol } = showcaseHarness(environmentId); + const item = { + name: DEFAULT, object: TASK, viewKind: 'list', label: 'ByNameRow', + config: { type: 'grid', data, columns: [{ field: 'title' }, { field: 'status' }] }, + }; + const saved = (await saveIn(protocol, DEFAULT, item, organizationId)) as any; + expect(saved?.success).toBe(true); + await expectBothDoors(protocol, DEFAULT, organizationId, (v) => expect(v?.label).toBe('ByNameRow')); + }); + } + }); + } + }); +}); + +/** + * #21558 on an object no code package ships: the refusal follows the readers' + * expansion, the expander's de-duplication included, so a container saved + * under the de-duplicated name its own expansion gave a member is refused + * too, and a container under its object's name still saves. + */ +describe('#21558 a container under its own expanded name, on a runtime-authored object', () => { + async function saveCrm(name: string, item: unknown) { + const harness = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(harness.engine); + const error = await protocol.saveMetaItem({ type: 'view', name, item }).then(() => null, (e: any) => e); + const viewRows = Array.from(harness.rows.values()).filter((r) => r.type === 'view'); + return { ...harness, protocol, error, viewRows }; + } + const list = { label: 'All Leads', type: 'grid', columns: [{ field: 'name' }] }; + const other = { label: 'Other', type: 'grid', columns: [{ field: 'company' }] }; + + for (const [saveName, member] of [ + ['crm_lead.default', { list }], + ['crm_lead.pipeline', { listViews: { pipeline: list } }], + // `listViews.default` takes `crm_lead.default` first, so the expander + // renames the bare `list` to `crm_lead.default_2`. + ['crm_lead.default_2', { listViews: { default: list }, list: other }], + ] as const) { + it(`saved under ${saveName}: refused VALIDATION_ERROR / 400, nothing stored or registered`, async () => { + const body = { name: saveName, object: 'crm_lead', ...member }; + expect(expandViewContainer('crm_lead', body).map((vi) => vi.name), 'the save name is its own expansion\'s') + .toContain(saveName); + const { error, viewRows, registered } = await saveCrm(saveName, body); + expect(error).toBeInstanceOf(Error); + expect({ code: error?.code, status: error?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + expect(viewRows).toEqual([]); + expect(registered.get('view')?.size ?? 0).toBe(0); + }); + } + + it('a container with no `name` is judged under the name the door stamps on it: refused under crm_lead.default', async () => { + const { error, viewRows } = await saveCrm('crm_lead.default', { object: 'crm_lead', list }); + expect({ code: error?.code, status: error?.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + expect(viewRows).toEqual([]); + }); + + it('CONTROL: the same container under its object\'s name saves, and its expansion fills crm_lead.default', async () => { + const { error, protocol } = await saveCrm('crm_lead', { name: 'crm_lead', object: 'crm_lead', list }); + expect(error).toBeNull(); + const listed: any = await protocol.getMetaItems({ type: 'view' }); + expect(switcherMatches(listed.items, 'crm_lead').map((v: any) => [v.name, v.label])).toEqual([['crm_lead.default', 'All Leads']]); + }); }); /** From f033e0ae366f19217925346b15cac63a13a64a07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:26:32 +0000 Subject: [PATCH 2/2] chore(changeset): the save door's refusal of a container under its own expanded name Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../21558-container-own-expansion-name.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/21558-container-own-expansion-name.md diff --git a/.changeset/21558-container-own-expansion-name.md b/.changeset/21558-container-own-expansion-name.md new file mode 100644 index 0000000000..0769b6e90b --- /dev/null +++ b/.changeset/21558-container-own-expansion-name.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +The runtime save door refuses a view container saved under a name its own expansion produces + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing at the runtime save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's `name` refusals shipped with. + +**What was accepted before.** `saveMetaItem`, which `PUT /api/v1/meta/view/:name` and the dispatcher's metadata save both call, accepted an aggregated view container (`list` / `form` / `listViews` / `formViews`) saved under one of the names its own expansion produces: for example `{ name: 'crm_lead.default', object: 'crm_lead', list: { … } }` saved as `crm_lead.default`, the name its bare `list` expands to. That row is the name's own stored row, and an expansion fills only names that have no row of their own (the object door adopts that rule in this same release), so the container's expansion never filled it. The object door (`GET /api/v1/meta/view?object=…`), which never lists a container, listed nothing under the name, and the by-name read answered the raw container. No door answered a view item for the name, and nothing said why. + +**What is refused now.** That save, with `VALIDATION_ERROR` / 400, before anything is stored or registered, in draft and in publish mode. Whether a name is one the container's own expansion produces is decided by the same expansion the read doors run, so every member kind (a bare or named `list`, `listViews`, `form`, `formViews`) and the expander's de-duplicated names (`…_2`) are judged where the readers place them. A container with no `name` is judged under the save name the door stamps on it. A container on another package's object expands under its own name, which is never the name it is saved under, so it is not refused. + +**What still saves.** A container under its object's name, which expands as before. A view item (a body carrying `viewKind`) under an expanded name, the sanctioned override for that name. The read doors are unchanged. A row stored in this shape before this change keeps its bytes and is served as before; `migrate meta --stored` and package duplication, which re-save stored rows through this door, now report such a row as failed with this refusal instead of re-saving it. + +**The fix.** Save the container under its object's name (`crm_lead`), or save a view item (`name`, `object`, `viewKind`, `config`) under the expanded name (`crm_lead.default`).