From 5b1458371bb65943e2539bb27d6419240ce78a85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:53:47 +0000 Subject: [PATCH 1/7] wip(metadata,metadata-protocol): every write door judges a body name against its row name Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../src/protocol.item-name-every-door.test.ts | 492 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 102 ++-- .../view-container-runtime-expansion.test.ts | 4 +- .../metadata/src/view-container-name.test.ts | 113 +++- packages/metadata/src/view-container-name.ts | 218 +++++--- 5 files changed, 816 insertions(+), 113 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.item-name-every-door.test.ts diff --git a/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts b/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts new file mode 100644 index 00000000000..bfb9edd6f4d --- /dev/null +++ b/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts @@ -0,0 +1,492 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21470] Every runtime door that writes a `sys_metadata` row refuses a body + * whose own `name` disagrees with the name it writes the row under — for + * every type, through the one judge (`savedItemNameRefusal`, + * `@objectstack/metadata/view-container-name`). + * + * --------------------------------------------------------------------------- + * The defect, measured on `origin/main` `e9dec3dab` before this change + * --------------------------------------------------------------------------- + * The registry write-through keys a written body by its OWN `name` + * (`hydrateOverlayIntoRegistry` → `registerItem(type, body, 'name')`), while + * the row is stored under the request's name. So a body whose `name` was not + * its row's answered under a name nobody saved it under, and under none by its + * own: + * + * - P6, a record view: row `crm_lead.mine`, body `name` `crm_lead.other` — + * accepted, registry key `crm_lead.other` only; + * - P7, a dashboard: row `dash_a`, body `name` `dash_b` — accepted, registry + * key `dash_b` only; + * - R1 / R2: `rollbackMetaItem` and `revertCommit`'s restore limb wrote such + * a stored history version back as the active row with ZERO `saveMetaItem` + * calls, and registered it under the body's `name`; + * - D1: `publishMetaItem` promoted such a stored draft the same way. + * + * View CONTAINERS were already refused at the save door (#21412) and stay + * pinned in `view-container-runtime-expansion.test.ts`; this file pins every + * other body at every write door. + * + * --------------------------------------------------------------------------- + * Staging a stored body the save door would now refuse + * --------------------------------------------------------------------------- + * After this change no door writes such a body, so R1, R2, D1 and the batch + * case stage one the way it exists in a deployment: as a row stored BEFORE the + * change. A clean body is written through the real door, and its stored bytes + * are then rewritten in the double (`residue()`), exactly as a pre-change row + * sits at rest. Each refusal is paired with a CONTROL through the same door + * on a clean body, so a door that refuses everything cannot pass. + * + * The kernel is the control-plane topology (`environmentId` undefined): the + * one on which a non-`object` type writes through to the shared registry + * (`applyRegistryWriteThrough`), which is where the second key appeared. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, imported from +// `@objectstack/metadata-core` and NOT from `@objectstack/objectql`: objectql +// depends on this package, so that import would close a dependency cycle. +import { + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, + assertEngineFindOnePredicate, +} from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; +} + +interface HistoryRow { + id: string; + type: string; + name: string; + version: number; + organization_id: string | null; + operation_type: string; + metadata?: string | null; +} + +/** ADR-0048 overlay key — (type, name, org, state, package_id). */ +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +/** Top-level eq + `$or` + explicit-NULL, the subset these paths emit. */ +function matchesWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesWhere(r, c))) return false; + continue; + } + if (v === undefined) continue; + if ((r as unknown as Record)[k] !== v) return false; + } + return true; +} + +/** + * A double that STORES rows and history and whose registry keys an item by + * its own `name`, the way `SchemaRegistry.registerItem` does — a no-op + * registry would pass every "nothing registered" assertion vacuously. + * Copied from `protocol.recovery-doors-emit-mutation.test.ts`, not imported, + * so each pin can fail on its own. + */ +function makeStubEngine() { + const rows = new Map(); + const historyRows: HistoryRow[] = []; + const registered = new Map>(); + let nextId = 0; + let commit: unknown = null; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + if (r) return { key: k, row: r }; + } + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const matchesHistory = (h: HistoryRow, w: Record): boolean => { + if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; + if (w.type !== undefined && h.type !== w.type) return false; + if (w.name !== undefined && h.name !== w.name) return false; + if (w.version !== undefined && h.version !== w.version) return false; + if (w.operation_type !== undefined && h.operation_type !== w.operation_type) return false; + return true; + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + if (table === 'sys_metadata_commit') return commit; + if (table === 'sys_metadata_history') { + return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null; + } + if (table !== 'sys_metadata') return null; + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts?: { where?: Record; limit?: number }) { + // Hold the caller's bound AFTER the filter, by PRESENCE — the + // objectql-double-limit contract. + const bound = (all: T[]): T[] => + typeof opts?.limit === 'number' ? all.slice(0, opts.limit) : all; + if (table === 'sys_metadata_history') { + return bound(historyRows.filter((h) => matchesHistory(h, opts?.where ?? {}))); + } + if (table !== 'sys_metadata') return []; + return bound(Array.from(rows.values()).filter((r) => matchesWhere(r, opts?.where ?? {}))); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_history') { + nextId += 1; + const h = { ...(data as unknown as HistoryRow), id: `h_${nextId}` }; + historyRows.push(h); + return { id: h.id }; + } + if (table !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { ...(data as unknown as Row), id: `r_${nextId}` }; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as unknown as Row) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async count() { return 0; }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() { return true; }, + async dropObjectSchema() { return true; }, + registry: { + registerItem: (type: string, item: any) => { + if (!registered.has(type)) registered.set(type, new Map()); + registered.get(type)!.set(item?.name, item); + }, + registerObject: () => {}, + unregisterObject: () => true, + removeObjectOverlay: () => {}, + removeRuntimeShadow: () => false, + removeOverlayEntry: () => true, + listItems: (type: string) => Array.from(registered.get(type)?.values() ?? []), + getItem: (type: string, name: string) => registered.get(type)?.get(name), + getObject: () => undefined, + getPackage: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + }, + }; + return { + engine, + rows, + historyRows, + registered, + serveCommit: (c: unknown) => { commit = c; }, + }; +} + +function makeProtocol() { + const h = makeStubEngine(); + // `environmentId` UNDEFINED on purpose — see the header. + const protocol = new ObjectStackProtocolImplementation(h.engine, () => new Map(), undefined) as any; + expect(protocol.environmentId).toBeUndefined(); + // `OS_METADATA_WRITABLE` is memoised process-wide; clear it rather than assume. + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const saveDoor = vi.spyOn(protocol, 'saveMetaItem'); + return { protocol, saveDoor, ...h }; +} + +type Harness = ReturnType; + +const dash = (name: string | undefined, label = 'D') => + (name === undefined ? { label, widgets: [] } : { name, label, widgets: [] }); +const recordView = (name: string | undefined) => ({ + ...(name === undefined ? {} : { name }), + object: 'crm_lead', + viewKind: 'list', + label: 'Mine', + config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: [{ field: 'name' }] }, +}); + +/** Every stored row of `type` as `{ row, state, bodyName }`. */ +const stored = (h: Harness, type: string) => Array.from(h.rows.values()) + .filter((r) => r.type === type) + .map((r) => ({ row: r.name, state: r.state, bodyName: JSON.parse(r.metadata).name })); +const keys = (h: Harness, type: string) => Array.from(h.registered.get(type)?.keys() ?? []); + +/** + * Rewrite a stored body's `name` in place — a row as it sits at rest when it + * was written before this change (see the header). Returns the row it edited. + */ +function residue(h: Harness, where: { type: string; name: string; state?: string; version?: number }, bodyName: unknown) { + if (where.version !== undefined) { + const row = h.historyRows.find((r) => r.type === where.type && r.name === where.name && r.version === where.version); + if (!row?.metadata) throw new Error(`no history row ${where.type}/${where.name}@${where.version}`); + row.metadata = JSON.stringify({ ...JSON.parse(row.metadata), name: bodyName }); + return row; + } + const row = Array.from(h.rows.values()) + .find((r) => r.type === where.type && r.name === where.name && r.state === (where.state ?? 'active')); + if (!row) throw new Error(`no ${where.state ?? 'active'} row ${where.type}/${where.name}`); + row.metadata = JSON.stringify({ ...JSON.parse(row.metadata), name: bodyName }); + return row; +} + +/** The minimum a rejection pin asserts: the ADR-0112 envelope, not "it threw". */ +function expectEnvelope(error: any) { + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe('VALIDATION_ERROR'); + expect(error.status).toBe(400); +} + +async function attempt(run: () => Promise): Promise<{ result: T | null; error: any }> { + try { + return { result: await run(), error: null }; + } catch (error) { + return { result: null, error }; + } +} + +afterEach(() => vi.restoreAllMocks()); + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. The save door — every type (P6, P7) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#21470] the save door refuses a body whose `name` is not its row\'s, for every type', () => { + it('P6: a record view saved as crm_lead.mine with `name` crm_lead.other is refused; nothing stored, nothing registered', async () => { + const h = makeProtocol(); + const { error } = await attempt(() => h.protocol.saveMetaItem({ + type: 'view', name: 'crm_lead.mine', item: recordView('crm_lead.other'), + })); + expectEnvelope(error); + expect(stored(h, 'view')).toEqual([]); + expect(keys(h, 'view')).toEqual([]); + }); + + it('P7: a dashboard saved as dash_a with `name` dash_b is refused; nothing stored, nothing registered', async () => { + const h = makeProtocol(); + const { error } = await attempt(() => h.protocol.saveMetaItem({ + type: 'dashboard', name: 'dash_a', item: dash('dash_b'), + })); + expectEnvelope(error); + expect(error.message).toContain("'dash_b'"); + expect(error.message).toContain("'dash_a'"); + expect(stored(h, 'dashboard')).toEqual([]); + expect(keys(h, 'dashboard')).toEqual([]); + }); + + it('a `translation` body with `name: \'\'` — which its schema accepts — is refused, not registered under \'\'', async () => { + // Measured before this change: stored under the row and registered + // under the empty string. Row 1's predicate: a `name` the body carries + // is set, whatever it is (the judge's header). + const h = makeProtocol(); + const { error } = await attempt(() => h.protocol.saveMetaItem({ + type: 'translation', name: 'crm_zh', item: { name: '', locale: 'zh-CN' }, + })); + expectEnvelope(error); + expect(stored(h, 'translation')).toEqual([]); + expect(keys(h, 'translation')).toEqual([]); + }); + + it('CONTROL: an equal `name` passes; an absent one passes the judge (a view is stamped, a dashboard meets its schema)', async () => { + const h = makeProtocol(); + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_a', item: dash('dash_a') }); + await h.protocol.saveMetaItem({ type: 'view', name: 'crm_lead.mine', item: recordView('crm_lead.mine') }); + await h.protocol.saveMetaItem({ type: 'view', name: 'crm_lead.stamped', item: recordView(undefined) }); + // The judge passes an absent dashboard `name`; the dashboard schema + // requires one, and answers in its own envelope. + const { error } = await attempt(() => h.protocol.saveMetaItem({ + type: 'dashboard', name: 'dash_none', item: dash(undefined), + })); + expect([error?.code, error?.status]).toEqual(['INVALID_METADATA', 422]); + + expect(stored(h, 'dashboard')).toEqual([{ row: 'dash_a', state: 'active', bodyName: 'dash_a' }]); + expect(stored(h, 'view').map((r) => [r.row, r.bodyName])).toEqual([ + ['crm_lead.mine', 'crm_lead.mine'], + ['crm_lead.stamped', 'crm_lead.stamped'], + ]); + // One registry key per row, and it is the row's. + expect(keys(h, 'dashboard')).toEqual(['dash_a']); + expect(keys(h, 'view')).toEqual(['crm_lead.mine', 'crm_lead.stamped']); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. The restore doors (R1, R2) +// ═══════════════════════════════════════════════════════════════════════════ + +/** dash_a at v1, v2, v3 through the real door; v2's stored body then carries `name` dash_b. */ +async function threeVersionsWithResidueAtV2(h: Harness) { + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_a', item: dash('dash_a', 'v1') }); + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_a', item: dash('dash_a', 'v2') }); + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_a', item: dash('dash_a', 'v3') }); + residue(h, { type: 'dashboard', name: 'dash_a', version: 2 }, 'dash_b'); + h.saveDoor.mockClear(); + return { historyBefore: h.historyRows.length, activeBefore: JSON.stringify(stored(h, 'dashboard')) }; +} + +const commitRow = (items: unknown[]) => ({ + id: 'c1', + commit_id: 'c1', + organization_id: null, + operation: 'apply', + message: 'the commit under revert', + created_at: '2026-01-01T00:00:00Z', + items: JSON.stringify(items), +}); + +describe('[#21470] the restore doors refuse a stored version whose `name` is not its row\'s', () => { + it('R1: rollbackMetaItem to it is refused; the active row, the history and the registry are unchanged', async () => { + const h = makeProtocol(); + const before = await threeVersionsWithResidueAtV2(h); + const keysBefore = keys(h, 'dashboard'); + + const { error } = await attempt(() => h.protocol.rollbackMetaItem({ type: 'dashboard', name: 'dash_a', toVersion: 2 })); + + expectEnvelope(error); + expect(error.message).toContain('the name it is restored under'); + expect(JSON.stringify(stored(h, 'dashboard'))).toBe(before.activeBefore); + expect(h.historyRows.length).toBe(before.historyBefore); + expect(keys(h, 'dashboard')).toEqual(keysBefore); + expect(keys(h, 'dashboard')).not.toContain('dash_b'); + expect(h.saveDoor).not.toHaveBeenCalled(); + }); + + it('R1 CONTROL: a rollback to a clean version restores it, under the row\'s one key', async () => { + const h = makeProtocol(); + await threeVersionsWithResidueAtV2(h); + + const res = await h.protocol.rollbackMetaItem({ type: 'dashboard', name: 'dash_a', toVersion: 1 }); + + expect(res.success).toBe(true); + expect(stored(h, 'dashboard')).toEqual([{ row: 'dash_a', state: 'active', bodyName: 'dash_a' }]); + expect(keys(h, 'dashboard')).toEqual(['dash_a']); + }); + + it('R2: revertCommit\'s restore limb reports it in failed[] with the envelope\'s code; nothing is written', async () => { + const h = makeProtocol(); + const before = await threeVersionsWithResidueAtV2(h); + h.serveCommit(commitRow([{ type: 'dashboard', name: 'dash_a', existedBefore: true, prevVersion: 2 }])); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const res = await h.protocol.revertCommit({ commitId: 'c1' }); + + expect(res.revertedCount).toBe(0); + expect(res.failed).toHaveLength(1); + expect(res.failed[0]).toMatchObject({ type: 'dashboard', name: 'dash_a', code: 'VALIDATION_ERROR' }); + expect(res.failed[0].error).toContain('the name it is restored under'); + expect(JSON.stringify(stored(h, 'dashboard'))).toBe(before.activeBefore); + expect(h.historyRows.length).toBe(before.historyBefore); + expect(keys(h, 'dashboard')).not.toContain('dash_b'); + expect(h.saveDoor).not.toHaveBeenCalled(); + }); + + it('R2 CONTROL: the restore limb restores a clean version', async () => { + const h = makeProtocol(); + await threeVersionsWithResidueAtV2(h); + h.serveCommit(commitRow([{ type: 'dashboard', name: 'dash_a', existedBefore: true, prevVersion: 1 }])); + + const res = await h.protocol.revertCommit({ commitId: 'c1' }); + + expect(res.revertedCount).toBe(1); + expect(res.failed).toEqual([]); + expect(keys(h, 'dashboard')).toEqual(['dash_a']); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. The draft promotion (D1, and the batch that shares it) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#21470] the draft promotion refuses a stored draft whose `name` is not its row\'s', () => { + it('D1: publishMetaItem of it is refused; no active row, no registry key, the draft kept', async () => { + const h = makeProtocol(); + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_d', item: dash('dash_d'), mode: 'draft' }); + residue(h, { type: 'dashboard', name: 'dash_d', state: 'draft' }, 'dash_e'); + h.saveDoor.mockClear(); + + const { error } = await attempt(() => h.protocol.publishMetaItem({ type: 'dashboard', name: 'dash_d' })); + + expectEnvelope(error); + expect(error.message).toContain('the name it is published under'); + expect(stored(h, 'dashboard')).toEqual([{ row: 'dash_d', state: 'draft', bodyName: 'dash_e' }]); + expect(keys(h, 'dashboard')).toEqual([]); + expect(h.saveDoor).not.toHaveBeenCalled(); + }); + + it('D1 CONTROL: a clean draft publishes under the row\'s one key', async () => { + const h = makeProtocol(); + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_d', item: dash('dash_d'), mode: 'draft' }); + + const res = await h.protocol.publishMetaItem({ type: 'dashboard', name: 'dash_d' }); + + expect(res.success).toBe(true); + expect(stored(h, 'dashboard')).toEqual([{ row: 'dash_d', state: 'active', bodyName: 'dash_d' }]); + expect(keys(h, 'dashboard')).toEqual(['dash_d']); + }); + + it('publishPackageDrafts: one refused draft aborts the batch as the authoring gate\'s refusal does — nothing goes live', async () => { + // ADR-0067 D2: a package publishes as a unit. The refused draft is + // reported with the envelope's code, its sibling as aborted with it, + // and neither reaches `active` or the registry. + const h = makeProtocol(); + const PKG = 'app.dash'; + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_ok', item: dash('dash_ok'), mode: 'draft', packageId: PKG }); + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_d', item: dash('dash_d'), mode: 'draft', packageId: PKG }); + residue(h, { type: 'dashboard', name: 'dash_d', state: 'draft' }, 'dash_e'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const res = await h.protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.success).toBe(false); + expect(res.outcome).toBe('refused'); + expect(res.publishedCount).toBe(0); + expect(res.failed.find((f: any) => f.name === 'dash_d')).toMatchObject({ type: 'dashboard', code: 'VALIDATION_ERROR' }); + expect(res.failed.map((f: any) => f.name).sort()).toEqual(['dash_d', 'dash_ok']); + expect(stored(h, 'dashboard').every((r) => r.state === 'draft')).toBe(true); + expect(keys(h, 'dashboard')).toEqual([]); + }); + + it('publishPackageDrafts CONTROL: the same batch with a clean draft publishes both', async () => { + const h = makeProtocol(); + const PKG = 'app.dash'; + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_ok', item: dash('dash_ok'), mode: 'draft', packageId: PKG }); + await h.protocol.saveMetaItem({ type: 'dashboard', name: 'dash_d', item: dash('dash_d'), mode: 'draft', packageId: PKG }); + + const res = await h.protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.outcome).toBe('published'); + expect(res.publishedCount).toBe(2); + expect(keys(h, 'dashboard').sort()).toEqual(['dash_d', 'dash_ok']); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ad70a9e7426..c41476c3401 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -94,10 +94,12 @@ import { // `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in // `@objectstack/metadata` (#5108). See `rethrowUnlessMetadataStoreUnprovisioned`. import { isMissingTableError } from '@objectstack/metadata/errors'; -// [#21412] The divergent view-container `name` refusal — the one judge the -// boot loop, `os validate` and the artifact/HMR loader call too; this door -// passes the name it files the row under. See `saveMetaItem`. -import { savedViewContainerNameRefusal } from '@objectstack/metadata/view-container-name'; +// [#21412, #21470] The divergent `name` refusal — the one judge the boot loop, +// `os validate` and the artifact/HMR loader call too. Every door here that +// writes a `sys_metadata` row passes the name it writes the row under: the save +// door (`saveMetaItem`), the restore doors (`rollbackMetaItem`, `revertCommit`) +// and the draft promotion (`promoteDraftForPublish`). +import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name'; import type { BatchUpdateRequest, BatchUpdateResponse, @@ -1222,10 +1224,10 @@ export { stripReadDecorations }; * into a record risks producing an invalid record (e.g. a non-`.` * name). Structural validity is enforced separately by the view metadata schema * during the spec-validation step. No-op for non-view types and bodies that - * already carry a `name`. [#21412] A CONTAINER's authored `name` reaches this - * function only when it equals `saveName`: `saveMetaItem` refuses a - * disagreeing one first, through `savedViewContainerNameRefusal`, so keeping - * the authored `name` can no longer file a container under a second key. + * already carry a `name`. [#21412, #21470] A view's authored `name` reaches + * this function only when it equals `saveName`: `saveMetaItem` refuses a + * disagreeing one first, through `savedItemNameRefusal`, so keeping the + * authored `name` can no longer file a view under a second key. * * When `baseline` is provided (the registry entry this overlay will shadow), * missing identity fields — `viewKind`, `object`, `label` — are inherited onto @@ -5292,6 +5294,32 @@ export class ObjectStackProtocolImplementation implements return channel ? (body) => channel.strip(body) : undefined; } + /** + * [#21470] What a restore passes to `repo.restoreVersion` as + * `deriveRestoredBody`: the divergent `name` refusal FIRST, then + * {@link restoredBodyDerivation}. A version stored before `saveMetaItem` + * judged every type can carry a `name` that is not its row's; written back, + * the write-through registers the row under that `name` and under none by + * its own. `restoreVersion` calls this on the very history body it read, + * before it reads the active row and before `put` — so a refused version + * writes nothing, the position the credential strip already has. The + * refusal is the save door's judge and envelope (`VALIDATION_ERROR` / 400): + * `rollbackMetaItem` rethrows it, `revertCommit` reports it per item in + * `failed[]`. A body that passes comes out of the strip, or byte for byte + * where the type has no credential channel. + * + * @param type The canonical (singular) type the row is restored under. + * @param name The name the row is restored under. + */ + private restoredBodyWriter(type: string, name: string): (historyBody: unknown) => unknown { + const derive = this.restoredBodyDerivation(type); + return (historyBody) => { + const nameRefusal = savedItemNameRefusal(type, historyBody, name, 'restore'); + if (nameRefusal) throw nameRefusal; + return derive ? derive(historyBody) : historyBody; + }; + } + /** * Run the registered authoring gate for an about-to-persist body (#3050). * No-op when no gate is registered for the type. A gate throw PROPAGATES @@ -17908,20 +17936,21 @@ export class ObjectStackProtocolImplementation implements // (#2555 — a console personalization PUT sends only the raw config). // See {@link normalizeViewMetadata}. { - // [#21412] FIRST, before the stamp below can keep an authored - // `name`: a view CONTAINER whose own `name` disagrees with the name - // this door files the row under is refused, `VALIDATION_ERROR` / - // 400, through the one judge the source registrars call. Accepted, - // it was stored under the row name and registered under the - // body's (`hydrateOverlayIntoRegistry` keys by `body.name`), so one - // document answered under two names. The key here is the save - // name, not the derived binding: this door keeps a container saved - // under a name other than its object (#13407, #21334), and the - // body it stamps for one must pass when sent back. A body with no - // `name` passes and is stamped below. Containers only — the - // every-type half is #21470. - if (singularType === 'view') { - const nameRefusal = savedViewContainerNameRefusal(request.item, request.name); + // [#21412, #21470] FIRST, before the stamp below can keep an + // authored `name`: a body of ANY type whose own `name` disagrees + // with the name this door files the row under is refused, + // `VALIDATION_ERROR` / 400, through the one judge the source + // registrars call. Accepted, it was stored under the row name and + // registered under the body's (`hydrateOverlayIntoRegistry` keys by + // `body.name`), so one row answered under a name nobody saved it + // under and under none by its own. The key here is the save name, + // not a view container's derived binding: this door keeps a + // container saved under a name other than its object (#13407, + // #21334), and the body it stamps for one must pass when sent back. + // A body with no `name` passes; a view's is stamped below. What + // counts as a set `name` per type is the judge's header. + { + const nameRefusal = savedItemNameRefusal(singularType, request.item, request.name, 'save'); if (nameRefusal) throw nameRefusal; } let baseline: unknown; @@ -19635,6 +19664,17 @@ export class ObjectStackProtocolImplementation implements { type: singularType, name: request.name, org: orgId ?? 'env' } as Parameters[0], { state: 'draft' }, ); + // [#21470] …and the divergent `name` refusal, on the same body and for + // the same reason: a draft stored before `saveMetaItem` judged every + // type can carry a `name` that is not its row's, and promoting it + // registers the row under that `name`. Refused here, with the save + // door's judge and envelope, before `repo.promoteDraft` writes — + // exactly as the authoring gate below refuses, so a batch publish + // aborts on it as it aborts on that gate. + if (draftForGate) { + const nameRefusal = savedItemNameRefusal(singularType, draftForGate.body, request.name, 'publish'); + if (nameRefusal) throw nameRefusal; + } // [#9176] The gate's return is its advisory half (#4717): captured and // handed out so `publishMetaItem` can attach it to the 2xx this // promotion is about to earn, exactly as `saveMetaItem` attaches its @@ -22888,14 +22928,17 @@ export class ObjectStackProtocolImplementation implements // the shape that ends in a `catch {}` swallowing a real outage // (#4867). Per ITEM, because a batch mixes bindings. const restorePackageId = await this.resolveOverlayPackageBinding(it.type, it.name, itemOrgId); - const restoreDerivation = this.restoredBodyDerivation(it.type); const restored = await repo.restoreVersion(ref, restoreToVersion, { actor, source: 'protocol.revertCommit', message: `revert commit ${request.commitId}`, intent, - // [#20790] R2 — the type's credential-channel strip. - ...(restoreDerivation ? { deriveRestoredBody: restoreDerivation } : {}), + // [#21470] The divergent `name` refusal, then [#20790] + // R2's credential-channel strip. A refused version lands + // in `failed[]` below, with nothing written. Judged under + // the SINGULAR type, the spelling the write-through + // registers under. See {@link restoredBodyWriter}. + deriveRestoredBody: this.restoredBodyWriter(PLURAL_TO_SINGULAR[it.type] ?? it.type, it.name), }); // [#6621] #4521 — a revert is a live write like any other: the // restored body must be the one the runtime dispatches on @@ -23281,16 +23324,17 @@ export class ObjectStackProtocolImplementation implements // real outage (#4867). const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, orgId); try { - const restoreDerivation = this.restoredBodyDerivation(singularType); const result = await repo.restoreVersion(ref, request.toVersion, { // #4556 — NULL, not 'system', for an actor-less rollback. actor: request.actor ?? null, source: 'protocol.rollbackMetaItem', ...(request.message ? { message: request.message } : {}), intent, - // [#20790] R2 — a rollback past the credential move keeps the - // write-only channel's current credential and stores none. - ...(restoreDerivation ? { deriveRestoredBody: restoreDerivation } : {}), + // [#21470] The divergent `name` refusal, rethrown below with + // nothing written; then [#20790] R2 — a rollback past the + // credential move keeps the write-only channel's current + // credential and stores none. See {@link restoredBodyWriter}. + deriveRestoredBody: this.restoredBodyWriter(singularType, request.name), }); // #4521 — a rollback is a live write like any other: the restored // body must be the one the runtime dispatches on immediately, not 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 3bd357d9c45..26be98d5f97 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -31,7 +31,7 @@ import { describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; import { expandViewContainer, isAggregatedViewContainer, ViewSchema } from '@objectstack/spec/ui'; import { MetadataPlugin } from '@objectstack/metadata'; -import { savedViewContainerNameRefusal } from '@objectstack/metadata/view-container-name'; +import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name'; import { ObjectStackProtocolImplementation } from './index.js'; interface Row { @@ -1005,7 +1005,7 @@ describe('#21412 the save door refuses a container whose own name disagrees with it('P1 is refused THROUGH the judge: the door throws exactly what it returns for that document', async () => { const body = named('lead_views', leadContainer); const { error } = await save('crm_lead', body); - expect(error.message).toBe(savedViewContainerNameRefusal(body, 'crm_lead')!.message); + expect(error.message).toBe(savedItemNameRefusal('view', body, 'crm_lead', 'save')!.message); }); it('P1 answers the envelope a source registrar answers for the same document', async () => { diff --git a/packages/metadata/src/view-container-name.test.ts b/packages/metadata/src/view-container-name.test.ts index eb551a8a0ef..da72114c4ee 100644 --- a/packages/metadata/src/view-container-name.test.ts +++ b/packages/metadata/src/view-container-name.test.ts @@ -1,16 +1,18 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #21412 — the divergent view-container `name` refusal, as ONE judge with two - * entries: the source registrars' (the key is DERIVED from the binding) and - * the runtime save door's (the key is the name the row is SAVED under). + * #21412, #21470 — the divergent `name` refusal, as ONE judge with two + * entries: the source registrars' (the key is DERIVED from a view container's + * binding) and the runtime write doors' (the key is the name the row is + * written under, for every type). * * The boot registrar's and `os validate`'s use of the derived entry stays * pinned where it always was (`packages/objectql`'s * `view-container-name-refusal.test.ts`, `packages/cli`'s * `validate-view-container-name.test.ts`), unedited: their words did not move. - * The save door's use is pinned at the door, in `packages/metadata-protocol`'s - * `view-container-runtime-expansion.test.ts`. This file pins the judge's two + * The write doors' use is pinned at the doors, in `packages/metadata-protocol`'s + * `view-container-runtime-expansion.test.ts` (view containers) and + * `protocol.item-name-every-door.test.ts` (every type, every write door). This file pins the judge's two * predicates against each other on the shapes that tell them apart, and the * artifact/HMR door's container branch, which lives in this package. * @@ -27,7 +29,7 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { savedViewContainerNameRefusal, viewContainerNameRefusal } from './view-container-name.js'; +import { savedItemNameRefusal, viewContainerNameRefusal } from './view-container-name.js'; import { MetadataPlugin } from './plugin.js'; const PKG = 'com.acme.crm'; @@ -37,6 +39,8 @@ const listArm = { label: 'All Leads', type: 'grid', columns: [{ field: 'name' }] const boundBody = (name?: string) => ({ ...(name === undefined ? {} : { name }), object: 'crm_lead', list: listArm }); /** No binding but its own `name`. */ const nameOnlyBody = (name: string) => ({ name, list: listArm }); +/** The save door's entry on a `view` body — the call `saveMetaItem` makes for one. */ +const savedContainer = (body: unknown, saveName: string) => savedItemNameRefusal('view', body, saveName, 'save'); /** The minimum a rejection pin asserts: the ADR-0112 envelope, not "it threw". */ function expectEnvelope(refusal: unknown): void { @@ -48,41 +52,110 @@ function expectEnvelope(refusal: unknown): void { describe('#21412 — the save door\'s entry judges the name the row is saved under', () => { it('P1: a body `name` that is neither the row nor the binding is refused, naming both values', () => { - const refusal = savedViewContainerNameRefusal(boundBody('lead_views'), 'crm_lead'); + const refusal = savedContainer(boundBody('lead_views'), 'crm_lead'); expectEnvelope(refusal); expect(refusal!.message).toContain("'lead_views'"); expect(refusal!.message).toContain("'crm_lead'"); }); it('P3: a body `name` equal to the binding but not to the row is refused', () => { - expectEnvelope(savedViewContainerNameRefusal(boundBody('crm_lead'), 'lead_views')); + expectEnvelope(savedContainer(boundBody('crm_lead'), 'lead_views')); }); it('P4: a body `name` that is the only binding, but not the row, is refused', () => { - expectEnvelope(savedViewContainerNameRefusal(nameOnlyBody('lead_views'), 'crm_lead')); + expectEnvelope(savedContainer(nameOnlyBody('lead_views'), 'crm_lead')); }); it('P2: a body `name` equal to the row passes, though the container binds elsewhere', () => { - expect(savedViewContainerNameRefusal(boundBody('lead_views'), 'lead_views')).toBeUndefined(); + expect(savedContainer(boundBody('lead_views'), 'lead_views')).toBeUndefined(); }); it('an absent, empty or non-string `name` refuses nothing — the door stamps or the schema judges it', () => { - expect(savedViewContainerNameRefusal(boundBody(), 'lead_views')).toBeUndefined(); - expect(savedViewContainerNameRefusal(boundBody(''), 'lead_views')).toBeUndefined(); - expect(savedViewContainerNameRefusal({ ...boundBody(), name: 7 }, 'lead_views')).toBeUndefined(); + expect(savedContainer(boundBody(), 'lead_views')).toBeUndefined(); + expect(savedContainer(boundBody(''), 'lead_views')).toBeUndefined(); + expect(savedContainer({ ...boundBody(), name: 7 }, 'lead_views')).toBeUndefined(); }); - it('SCOPE: a standalone ViewItem is not judged here — the every-type half is #21470', () => { + it('SCOPE (flipped by #21470): a standalone ViewItem and any other view body are judged too', () => { const record = { name: 'crm_lead.other', object: 'crm_lead', viewKind: 'list', list: listArm }; - expect(savedViewContainerNameRefusal(record, 'crm_lead.mine')).toBeUndefined(); - expect(savedViewContainerNameRefusal({ name: 'b', label: 'not a container' }, 'a')).toBeUndefined(); + expectEnvelope(savedContainer(record, 'crm_lead.mine')); + expectEnvelope(savedContainer({ name: 'b', label: 'not a container' }, 'a')); + // …and the agreeing ones still pass. + expect(savedContainer({ ...record, name: 'crm_lead.mine' }, 'crm_lead.mine')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// #21470 — the write-door entry judges EVERY type, at every write door. +// --------------------------------------------------------------------------- + +describe('#21470 — the write-door entry judges every type', () => { + const dash = (name?: unknown) => ({ ...(name === undefined ? {} : { name }), label: 'D', widgets: [] }); + + it('P7: a dashboard whose `name` is not its row is refused, naming both values and the type', () => { + const refusal = savedItemNameRefusal('dashboard', dash('dash_b'), 'dash_a', 'save'); + expectEnvelope(refusal); + expect(refusal!.message).toContain("'dash_b'"); + expect(refusal!.message).toContain("'dash_a'"); + expect(refusal!.message.startsWith('Invalid dashboard: ')).toBe(true); + }); + + it('an equal or absent `name` passes at every door', () => { + for (const door of ['save', 'restore', 'publish'] as const) { + expect(savedItemNameRefusal('dashboard', dash('dash_a'), 'dash_a', door)).toBeUndefined(); + expect(savedItemNameRefusal('dashboard', dash(), 'dash_a', door)).toBeUndefined(); + expect(savedItemNameRefusal('view', boundBody(), 'crm_lead', door)).toBeUndefined(); + } + }); + + it('a body that is not a document is not this judge\'s (the door\'s own guards and the schema answer it)', () => { + for (const body of [null, undefined, 'dash_b', ['dash_b'], 7]) { + expect(savedItemNameRefusal('dashboard', body, 'dash_a', 'save')).toBeUndefined(); + } + }); + + it('row 1\'s predicate off the view stamp: an empty, null or non-string `name` is SET and refused', () => { + // The registry keys a body by `String(name)` whatever it is, and a + // `translation` schema accepts `name: ''` (measured), so none of these + // may reach persistence under a row named otherwise. + for (const name of ['', null, 7, false]) { + expectEnvelope(savedItemNameRefusal('translation', { name, locale: 'zh-CN' }, 'crm_zh', 'save')); + expectEnvelope(savedItemNameRefusal('dashboard', dash(name), 'dash_a', 'save')); + } + }); + + it('the view stamp is the SAVE door\'s: a falsy view `name` passes there and is refused at restore and publish', () => { + // `normalizeViewMetadata` stamps a falsy view `name` after the save + // door's judge; the restore and publish doors stamp nothing. + expect(savedItemNameRefusal('view', boundBody(''), 'crm_lead', 'save')).toBeUndefined(); + expectEnvelope(savedItemNameRefusal('view', boundBody(''), 'crm_lead', 'restore')); + expectEnvelope(savedItemNameRefusal('view', boundBody(''), 'crm_lead', 'publish')); + }); + + it('the remedy is true per type: "drop `name`" only where the door stamps a missing one', () => { + const view = savedItemNameRefusal('view', boundBody('lead_views'), 'crm_lead', 'save')!; + expect(view.message.endsWith("Register under one name: drop `name`, or set it to 'crm_lead'.")).toBe(true); + const other = savedItemNameRefusal('dashboard', dash('dash_b'), 'dash_a', 'save')!; + expect(other.message.endsWith("Register under one name: set `name` to 'dash_a'.")).toBe(true); + expect(other.message).not.toContain('drop `name`'); + }); + + it('the restore and publish doors prescribe the save that fixes a body their caller cannot edit', () => { + const restored = savedItemNameRefusal('dashboard', dash('dash_b'), 'dash_a', 'restore')!; + expectEnvelope(restored); + expect(restored.message).toContain('the name it is restored under'); + expect(restored.message).toContain("save 'dash_a' with `name` set to 'dash_a', instead of restoring this version"); + const published = savedItemNameRefusal('dashboard', dash('dash_b'), 'dash_a', 'publish')!; + expectEnvelope(published); + expect(published.message).toContain('the name it is published under'); + expect(published.message).toContain("save the draft again with `name` set to 'dash_a', then publish it"); }); }); describe('#21412 — the two entries are one judgement keyed differently, not two rules', () => { it('on P1 both entries refuse, in one envelope', () => { const derived = viewContainerNameRefusal(boundBody('lead_views'), 'manifest', PKG); - const saved = savedViewContainerNameRefusal(boundBody('lead_views'), 'crm_lead'); + const saved = savedContainer(boundBody('lead_views'), 'crm_lead'); expectEnvelope(derived); expectEnvelope(saved); }); @@ -90,13 +163,13 @@ describe('#21412 — the two entries are one judgement keyed differently, not tw it('they part exactly where the derived key and the save name part (P2, P3, P4)', () => { // P2: derived refuses (the name is not the binding); saved passes. expectEnvelope(viewContainerNameRefusal(boundBody('lead_views'), 'manifest', PKG)); - expect(savedViewContainerNameRefusal(boundBody('lead_views'), 'lead_views')).toBeUndefined(); + expect(savedContainer(boundBody('lead_views'), 'lead_views')).toBeUndefined(); // P3: derived passes (the name IS the binding); saved refuses. expect(viewContainerNameRefusal(boundBody('crm_lead'), 'manifest', PKG)).toBeUndefined(); - expectEnvelope(savedViewContainerNameRefusal(boundBody('crm_lead'), 'lead_views')); + expectEnvelope(savedContainer(boundBody('crm_lead'), 'lead_views')); // P4: derived passes (the binding falls back to the name); saved refuses. expect(viewContainerNameRefusal(nameOnlyBody('lead_views'), 'manifest', PKG)).toBeUndefined(); - expectEnvelope(savedViewContainerNameRefusal(nameOnlyBody('lead_views'), 'crm_lead')); + expectEnvelope(savedContainer(nameOnlyBody('lead_views'), 'crm_lead')); }); it('the derived entry keeps boot\'s precondition: a falsy derived key refuses nothing', () => { diff --git a/packages/metadata/src/view-container-name.ts b/packages/metadata/src/view-container-name.ts index 1cad1d06976..9d7c6401ab8 100644 --- a/packages/metadata/src/view-container-name.ts +++ b/packages/metadata/src/view-container-name.ts @@ -1,30 +1,41 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `@objectstack/metadata/view-container-name` — the divergent view-container - * `name` refusal: ONE judge, called by every door that files a container. + * `@objectstack/metadata/view-container-name` — the divergent `name` refusal: + * ONE judge, called by every door that files a metadata item under a key. * * ## What it judges * - * A container's own `name`, when set, must equal the key the door files the - * container under. A disagreement is refused: resolving it silently in either - * direction files the item under a key the author never wrote (#7378 row 1). - * The maintainer's ruling of 2026-09-03 (direction 2) made the source - * registrars refuse it; the runtime save door is the third door, ruled on - * #21412 (seat answer, Q1 A). + * An item's own `name`, when set, must equal the key the door files the item + * under. A disagreement is refused: resolving it silently in either direction + * files the item under a key the author never wrote (#7378 row 1, the rule + * `IMetadataService.register` states for every type). The maintainer's ruling + * of 2026-09-03 (direction 2) made the source registrars refuse it for a view + * container; the runtime save door joined for containers on #21412 (seat + * answer, Q1 A) and for every type on #21470 (seat answer, Q1 A and Q2 A). * * The doors differ in WHERE their key comes from, and only there: * * - **The source registrars** — the ObjectQL boot loop, `os validate` / * `os compile` (the same judge, through `@objectstack/objectql`) and the - * artifact/HMR loader (`plugin.ts`) — file a container under the object it - * binds to, so their key is DERIVED: {@link viewContainerNameRefusal}. - * - **The runtime save door** (`saveMetaItem`, which REST `PUT - * /meta/view/:name` and the dispatcher both call) files the row under the - * name it is SAVED under, and that name is not always the binding: the - * door keeps a container saved under a name other than its object (#13407) - * and expands one on another package's object under its own name (#21334). - * So its key is the save name: {@link savedViewContainerNameRefusal}. + * artifact/HMR loader (`plugin.ts`) — file a view container under the + * object it binds to, so their key is DERIVED: {@link viewContainerNameRefusal}. + * - **The runtime write doors** of `@objectstack/metadata-protocol` file a + * `sys_metadata` row under the name the request names, and register the + * row's body under the body's own `name` — so the two must agree, for every + * type: {@link savedItemNameRefusal}. Three doors write a row: + * - `save` — `saveMetaItem`, which REST `PUT /meta/:type/:name` and the + * dispatcher both call. Its key is the save name, which for a view + * container is not always the binding: the door keeps a container saved + * under a name other than its object (#13407) and expands one on + * another package's object under its own name (#21334); + * - `restore` — `rollbackMetaItem` and `revertCommit`, which write a + * stored history version back as the active row; + * - `publish` — the draft promotion `publishMetaItem` and + * `publishPackageDrafts` share, which writes a stored draft as the + * active row. + * A body stored before the save door judged every type reaches the last + * two without passing the first; that is why they judge too. * * Judging the save door against the derived key instead was measured and * refused (#21412 probes P2–P4): it refuses the body that same door stores @@ -39,33 +50,50 @@ * {@link isAggregatedViewContainer}, and the artifact door derives the same * value before it registers. Taking the key as a parameter there would make * every one of those callers re-derive it, a second spelling of "which - * derivation does a source registrar use" — so that entry keeps deriving. The - * save door's key is not a derivation at all; it is the request's own name, + * derivation does a source registrar use" — so that entry keeps deriving. A + * write door's key is not a derivation at all; it is the request's own name, * which is why that entry takes it. * - * ## The gate, carried whole + * ## What counts as SET * - * * {@link isAggregatedViewContainer} — the CONTAINER branch only. A - * standalone ViewItem's `name` is its identity, not a binding (the - * every-type half at the save door is #21470); - * * `name` a non-empty string AND different from the key. A container with - * no `name` is untouched (the save door stamps one); - * * a falsy key refuses nothing: the boot loop warns and skips an entry - * whose derived key is falsy, and `deriveViewContainerObject`'s `??` chain - * can keep `''` (`list: { data: { object: '' } }`), so a door that calls - * this alone cannot refuse what boot skips. + * * The derived entry judges the CONTAINER branch only + * ({@link isAggregatedViewContainer}): a standalone ViewItem's `name` is + * its identity, not a binding. A `name` is set when it is a non-empty + * string; a falsy derived key refuses nothing, because the boot loop warns + * and skips an entry whose derived key is falsy, and + * `deriveViewContainerObject`'s `??` chain can keep `''` + * (`list: { data: { object: '' } }`), so a door that calls this alone + * cannot refuse what boot skips. + * * The write-door entry judges every type, with row 1's own predicate: a + * `name` is set when the body carries one at all (`!== undefined`) — + * `''`, `null` and a non-string included, because the registry keys the + * body by `String(name)` whatever it is (measured: a `translation` body's + * schema accepts `name: ''`, and a type with no schema accepts anything). + * ONE exception, and it is the door's, not the type's: the save door + * STAMPS a missing view name (`normalizeViewMetadata`, after this judge), + * and a falsy one is missing to it — so for a `view` at the `save` door a + * `name` is set only when it is a non-empty string. A truthy non-string + * view `name` is the view schema's to refuse (measured: 422). The restore + * and publish doors stamp nothing, so they take row 1's predicate for + * views too. * * ## The words * - * One template; each door supplies only where its key came from - * ({@link KeyOrigin}). The source registrars' rendering is byte for byte the - * words the boot loop and `os validate` have always printed. The runtime - * string carries NO tracker id (`check:doc-authoring`). The envelope is - * ADR-0112's `VALIDATION_ERROR` / 400 at every door. + * One template; each door supplies only where its key came from and what the + * author can do about it ({@link KeyOrigin}). The source registrars' rendering + * is byte for byte the words the boot loop and `os validate` have always + * printed, and the save door's rendering for a view container is byte for byte + * the words it printed before every type joined. The remedy is true per door + * and per type: "drop `name`" only where the door stamps a missing name (a + * view at the save door); "set `name`" where the schema requires one; and at + * the restore and publish doors, which write a stored body the caller cannot + * edit, the save that fixes it. The runtime string carries NO tracker id + * (`check:doc-authoring`). The envelope is ADR-0112's `VALIDATION_ERROR` / + * 400 at every door. * * ## Why a subpath of its own * - * `@objectstack/metadata` is the one layer all three doors already depend on + * `@objectstack/metadata` is the one layer every door already depends on * (`@objectstack/objectql` and `@objectstack/metadata-protocol` list it, and * the artifact door lives in it); `@objectstack/core` cannot host it, because * `@objectstack/metadata` lists core and the judge needs the derivation that @@ -87,25 +115,39 @@ export interface ViewContainerNameRefusal extends Error { } /** - * Where a door's key came from — the only words that differ between doors. - * `subject` names the container and its source, `key` says what the key is, - * `keyDetail` follows the key, and `alsoRefused` follows the shared reason. + * Where a door's key came from and what fixes the disagreement — the only + * words that differ between doors. `subject` names the item and its source, + * `owner` whose `name` it is, `key` what the key is, `keyDetail` follows the + * key, `alsoRefused` follows the shared reason, and `remedy` follows + * "Register under one name: ". */ interface KeyOrigin { subject: string; + owner: string; key: string; keyDetail: string; alsoRefused: string; + remedy: string; } -function refusal(name: string, key: string, origin: KeyOrigin): ViewContainerNameRefusal { +/** The owner words of a view container — the words every door printed before every type joined. */ +const CONTAINER_OWNER = "the container's"; + +/** The remedy where the door stamps a missing `name`: the words the container doors always printed. */ +const stampedRemedy = (key: string): string => `drop \`name\`, or set it to '${key}'`; + +/** A `name` as the words show it: quoted when it is a string, its JSON otherwise. */ +function shown(name: unknown): string { + return typeof name === 'string' ? `'${name}'` : String(JSON.stringify(name)); +} + +function refusal(name: unknown, key: string, origin: KeyOrigin): ViewContainerNameRefusal { const err = new Error( - `Invalid ${origin.subject}: the container's own ` - + `\`name\` is '${name}', which disagrees with ${origin.key}, ` + `Invalid ${origin.subject}: ${origin.owner} own ` + + `\`name\` is ${shown(name)}, which disagrees with ${origin.key}, ` + `'${key}'${origin.keyDetail}. A disagreement is almost always an authoring bug, and resolving ` + 'it silently in either direction can file the item under a key the caller never wrote ' - + `(refuse loudly, locate the mismatch)${origin.alsoRefused}. Register under one name: drop \`name\`, or set it to ` - + `'${key}'.`, + + `(refuse loudly, locate the mismatch)${origin.alsoRefused}. Register under one name: ${origin.remedy}.`, ) as ViewContainerNameRefusal; err.code = 'VALIDATION_ERROR'; err.status = 400; @@ -113,16 +155,23 @@ function refusal(name: string, key: string, origin: KeyOrigin): ViewContainerNam return err; } -/** The judgement every door shares: a set own `name` that is not the key. */ -function judge(container: unknown, key: string | undefined, origin: () => KeyOrigin): ViewContainerNameRefusal | undefined { - if (!isAggregatedViewContainer(container)) return undefined; - const name = (container as { name?: unknown }).name; - if (typeof name !== 'string' || !name) return undefined; +/** + * The judgement every door shares: a SET own `name` (each entry decides what + * set means for its door, see the header) that is not the key. A falsy key + * refuses nothing. + */ +function judge( + name: unknown, + key: string | undefined, + origin: (key: string) => KeyOrigin, +): ViewContainerNameRefusal | undefined { if (!key) return undefined; if (name === key) return undefined; - return refusal(name, key, origin()); + return refusal(name, key, origin(key)); } +const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value !== ''; + /** * Judge one `views:` entry at a SOURCE registrar: the refusal when it is an * aggregated container whose own `name` disagrees with the object key it binds @@ -141,30 +190,75 @@ export function viewContainerNameRefusal( sourceLabel: string, ownerId: string | undefined, ): ViewContainerNameRefusal | undefined { - return judge(container, deriveViewContainerObject(container), () => ({ + if (!isAggregatedViewContainer(container)) return undefined; + const name = (container as { name?: unknown }).name; + if (!isNonEmptyString(name)) return undefined; + return judge(name, deriveViewContainerObject(container), (key) => ({ subject: `\`views:\` container from ${sourceLabel} '${ownerId}'`, + owner: CONTAINER_OWNER, key: 'the object key it binds to', keyDetail: ' (derived from its own `object`, else `list.data.object` / `form.data.object`)', alsoRefused: ' — the artifact/HMR loader refuses this same document', + remedy: stampedRemedy(key), })); } /** - * Judge one view body at the runtime SAVE door: the refusal when it is an - * aggregated container whose own `name` disagrees with the name it is saved - * under, else `undefined`. Call it before the door stamps a missing `name`. + * Judge one metadata body at a runtime WRITE door, for every type: the + * refusal when its own `name` is set and disagrees with the name the door + * writes the row under, else `undefined`. Pure: it throws nothing and writes + * nothing; the door throws what it returns, before anything is stored or + * registered. * - * @param container The request's view body. - * @param saveName The name the door files the row under (`request.name`). + * @param type The canonical (singular) metadata type the door writes. + * @param item The body the door is about to write. + * @param saveName The name the door writes the row under (`request.name`). + * @param door Which door writes it: `save` (`saveMetaItem`, which stamps + * a missing view `name` after this call), `restore` (`rollbackMetaItem`, + * `revertCommit`) or `publish` (the draft promotion). It decides what counts + * as set and which remedy is true — see the header. */ -export function savedViewContainerNameRefusal( - container: unknown, +export function savedItemNameRefusal( + type: string, + item: unknown, saveName: string, + door: 'save' | 'restore' | 'publish', ): ViewContainerNameRefusal | undefined { - return judge(container, saveName, () => ({ - subject: 'view container', - key: 'the name it is saved under', - keyDetail: '', - alsoRefused: '', - })); + if (typeof item !== 'object' || item === null || Array.isArray(item)) return undefined; + const name = (item as { name?: unknown }).name; + const isView = type === 'view'; + const stampsMissingName = door === 'save' && isView; + if (stampsMissingName ? !isNonEmptyString(name) : name === undefined) return undefined; + const container = isView && isAggregatedViewContainer(item); + const noun = container ? 'view container' : type; + return judge(name, saveName, (key) => { + const fixedName = isView ? `\`name\` dropped or set to '${key}'` : `\`name\` set to '${key}'`; + if (door === 'save') { + return { + subject: noun, + owner: container ? CONTAINER_OWNER : 'its', + key: 'the name it is saved under', + keyDetail: '', + alsoRefused: '', + remedy: isView ? stampedRemedy(key) : `set \`name\` to '${key}'`, + }; + } + return door === 'restore' + ? { + subject: `${noun} version`, + owner: container ? CONTAINER_OWNER : 'its', + key: 'the name it is restored under', + keyDetail: '', + alsoRefused: '', + remedy: `save '${key}' with ${fixedName}, instead of restoring this version`, + } + : { + subject: `${noun} draft`, + owner: container ? CONTAINER_OWNER : 'its', + key: 'the name it is published under', + keyDetail: '', + alsoRefused: '', + remedy: `save the draft again with ${fixedName}, then publish it`, + }; + }); } From 2cc718d92c6d9332c5ecab3ff59c646d1c212ff2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:57:08 +0000 Subject: [PATCH 2/7] wip: changesets for the every-type write-door name judge; correct the two pending 21412 sentences Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- ...adata-protocol-save-door-container-name.md | 2 +- ...1412-metadata-view-container-name-judge.md | 2 +- ...ata-protocol-every-write-door-item-name.md | 21 +++++++++++++++++++ ...470-metadata-write-door-item-name-judge.md | 12 +++++++++++ .../src/protocol.item-name-every-door.test.ts | 15 ++++++++++++- 5 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .changeset/21470-metadata-protocol-every-write-door-item-name.md create mode 100644 .changeset/21470-metadata-write-door-item-name-judge.md diff --git a/.changeset/21412-metadata-protocol-save-door-container-name.md b/.changeset/21412-metadata-protocol-save-door-container-name.md index c271c39772d..21e5e137d12 100644 --- a/.changeset/21412-metadata-protocol-save-door-container-name.md +++ b/.changeset/21412-metadata-protocol-save-door-container-name.md @@ -16,4 +16,4 @@ Clause-②: no (narrowing) **The fix.** Drop the body's `name` (the door stamps the save name), or set it to the name the container is saved under. -Not judged here: a standalone view record (`viewKind`) and every other metadata type. +A standalone view record (`viewKind`) and every other metadata type are judged too, by the same release's every-type refusal at the save, restore and publish doors (its own entry). diff --git a/.changeset/21412-metadata-view-container-name-judge.md b/.changeset/21412-metadata-view-container-name-judge.md index c8df04b212b..5d52bb952d2 100644 --- a/.changeset/21412-metadata-view-container-name-judge.md +++ b/.changeset/21412-metadata-view-container-name-judge.md @@ -6,5 +6,5 @@ One judge for a view container's own `name` at every door that files a container Clause-②: yes -- New subpath `@objectstack/metadata/view-container-name`. It exports `viewContainerNameRefusal(container, sourceLabel, ownerId)`, the source registrars' entry, whose key is the object the container binds to (its own `object`, else `list.data.object` / `form.data.object`). It also exports `savedViewContainerNameRefusal(container, saveName)`, the runtime save door's entry, whose key is the name the row is saved under, and the `ViewContainerNameRefusal` type. Both return a `VALIDATION_ERROR` / 400 refusal for an aggregated view container whose own `name` is set and differs from that key, and `undefined` otherwise. A container with no `name`, and a standalone view record (`viewKind`), are not judged. +- New subpath `@objectstack/metadata/view-container-name`. It exports `viewContainerNameRefusal(container, sourceLabel, ownerId)`, the source registrars' entry, whose key is the object the container binds to (its own `object`, else `list.data.object` / `form.data.object`). It returns a `VALIDATION_ERROR` / 400 refusal for an aggregated view container whose own `name` is set and differs from that key, and `undefined` otherwise; a container with no `name`, and a standalone view record (`viewKind`), are not judged by it. The subpath also exports `savedItemNameRefusal(type, item, saveName, door)`, the runtime write doors' entry, which judges every metadata type against the name the row is written under, and the `ViewContainerNameRefusal` type both entries return. - The artifact/HMR loader's container branch now refuses such a container through the judge, before it files anything. What it refuses and the envelope are unchanged (`VALIDATION_ERROR` / 400). The message is now the judge's, the words the ObjectQL boot loop and `os validate` print, where it was the generic `IMetadataService.register` contract's. diff --git a/.changeset/21470-metadata-protocol-every-write-door-item-name.md b/.changeset/21470-metadata-protocol-every-write-door-item-name.md new file mode 100644 index 00000000000..cd953b47416 --- /dev/null +++ b/.changeset/21470-metadata-protocol-every-write-door-item-name.md @@ -0,0 +1,21 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +Every runtime door that writes a metadata row refuses a body whose own `name` disagrees with the row's name, for every metadata type + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing at the runtime write doors, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the view-container half of this refusal takes in the same release. + +**What was accepted before.** The runtime stores a metadata row under the name the request names and registers its body under the body's own `name`. Four doors accepted a body whose `name` was not the row's, so the row answered under a name nobody saved it under, and under none by its own: + +- `saveMetaItem`, which `PUT /api/v1/meta/:type/:name` and the dispatcher's metadata save both call, for every type but a view container (a dashboard saved as `dash_a` with `name: 'dash_b'` registered as `dash_b`; a record view saved as `crm_lead.mine` with `name: 'crm_lead.other'` registered as `crm_lead.other`); +- `rollbackMetaItem` and `revertCommit`, which wrote such a stored history version back as the active row without passing `saveMetaItem`; +- `publishMetaItem` and `publishPackageDrafts`, which promoted such a stored draft the same way. + +**What is refused now.** Each of those bodies, with `VALIDATION_ERROR` / 400, before anything is stored or registered, through the judge the view-container refusal already used (`savedItemNameRefusal`, `@objectstack/metadata/view-container-name`). `rollbackMetaItem` and `publishMetaItem` throw it. `revertCommit` reports the item in `failed[]` with `code: 'VALIDATION_ERROR'`. `publishPackageDrafts` aborts the batch on it, as it does on any refused draft: nothing in the batch is published. A body with no `name` is accepted as before. A `name` the body carries is judged whatever its value; a `translation` saved with `name: ''`, which its schema accepts, is now refused instead of being registered under the empty string. A view at the save door is the exception: a missing or empty view `name` is still stamped with the save name. Where the type's schema already refused such a body (an empty or non-string `name` on most types, any `name` on a `seed`, whose schema declares none), the answer is now this refusal (`VALIDATION_ERROR` / 400) instead of the schema's `INVALID_METADATA` / 422; nothing is stored either way. + +**The fix.** Set the body's `name` to the name you save it under, or drop it where the type allows (a view's is stamped). To bring back a version or a draft that carries another `name`, save the item again with `name` set to its row's name, and publish that save if it is a draft. diff --git a/.changeset/21470-metadata-write-door-item-name-judge.md b/.changeset/21470-metadata-write-door-item-name-judge.md new file mode 100644 index 00000000000..38826833b58 --- /dev/null +++ b/.changeset/21470-metadata-write-door-item-name-judge.md @@ -0,0 +1,12 @@ +--- +'@objectstack/metadata': minor +--- + +The runtime write doors' `name` judge covers every metadata type: `savedItemNameRefusal` replaces `savedViewContainerNameRefusal` on `@objectstack/metadata/view-container-name` + +Clause-②: yes + +- `savedItemNameRefusal(type, item, saveName, door)` is the one entry the runtime write doors of `@objectstack/metadata-protocol` call. It returns a `VALIDATION_ERROR` / 400 refusal when a body of any type carries its own `name` and that `name` differs from the name the row is written under, and `undefined` otherwise. `door` is `'save'`, `'restore'` or `'publish'`. A body with no `name` passes. A `name` the body does carry is judged whatever its value (`''`, `null` and non-strings included), with one exception: a `view` at the `'save'` door, which stamps a missing name there, is judged only on a non-empty string `name`. +- It replaces `savedViewContainerNameRefusal(container, saveName)`, which judged view containers only. That export was added to this subpath in this same release cycle and never shipped in a published version, so no published export is removed. +- The words name the type and give a remedy that works for it: "drop `name`, or set it to KEY" only for a view at the save door, "set `name` to KEY" for every other type, and at the restore and publish doors the save that fixes the stored body. For a view container at the save door the message is byte for byte the one `savedViewContainerNameRefusal` returned. +- `viewContainerNameRefusal` (the source registrars' entry), its words and the `ViewContainerNameRefusal` type are unchanged. diff --git a/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts b/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts index bfb9edd6f4d..51448e83b3b 100644 --- a/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts +++ b/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts @@ -182,8 +182,21 @@ function makeStubEngine() { return { deleted: 1 }; }, async count() { return 0; }, + // A transaction that ROLLS BACK, the property `publishPackageDrafts`' + // Phase 1 stands on (ADR-0067 D2): a throw restores the rows and the + // history it started with. The registry is not part of it — Phase 1 + // defers every registry mutation to Phase 2 — so it is not snapshotted. async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { - return cb(undefined, { owned: true }); + const rowsAtBegin = new Map(rows); + const historyAtBegin = historyRows.slice(); + try { + return await cb(undefined, { owned: true }); + } catch (error) { + rows.clear(); + for (const [k, r] of rowsAtBegin) rows.set(k, r); + historyRows.splice(0, historyRows.length, ...historyAtBegin); + throw error; + } }, async syncObjectSchema() { return true; }, async dropObjectSchema() { return true; }, From 109c0d6f057090d76e5dc18f7f200c6f62a66a71 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 02:57:26 +0000 Subject: [PATCH 3/7] wip: exact door wording in the protocol changeset Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../21470-metadata-protocol-every-write-door-item-name.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/21470-metadata-protocol-every-write-door-item-name.md b/.changeset/21470-metadata-protocol-every-write-door-item-name.md index cd953b47416..937ada38fc4 100644 --- a/.changeset/21470-metadata-protocol-every-write-door-item-name.md +++ b/.changeset/21470-metadata-protocol-every-write-door-item-name.md @@ -6,11 +6,11 @@ Every runtime door that writes a metadata row refuses a body whose own `name` di Clause-②: no (narrowing) - + **BREAKING** accept-set narrowing at the runtime write doors, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the view-container half of this refusal takes in the same release. -**What was accepted before.** The runtime stores a metadata row under the name the request names and registers its body under the body's own `name`. Four doors accepted a body whose `name` was not the row's, so the row answered under a name nobody saved it under, and under none by its own: +**What was accepted before.** The runtime stores a metadata row under the name the request names and registers its body under the body's own `name`. These doors accepted a body whose `name` was not the row's, so the row answered under a name nobody saved it under, and under none by its own: - `saveMetaItem`, which `PUT /api/v1/meta/:type/:name` and the dispatcher's metadata save both call, for every type but a view container (a dashboard saved as `dash_a` with `name: 'dash_b'` registered as `dash_b`; a record view saved as `crm_lead.mine` with `name: 'crm_lead.other'` registered as `crm_lead.other`); - `rollbackMetaItem` and `revertCommit`, which wrote such a stored history version back as the active row without passing `saveMetaItem`; From a4d3d989f767358fb008c39530c632c6849e1f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:09:25 +0000 Subject: [PATCH 4/7] wip: per-type remedies (field, both directions); fixture triage for the every-type judge Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../src/protocol.code-only-types.test.ts | 7 ++- ...l.destructive-gate-reachable-types.test.ts | 19 +++++- .../src/protocol.item-name-grammar.test.ts | 10 ++- ...tocol.runtime-gate-stored-universe.test.ts | 8 ++- .../metadata/src/view-container-name.test.ts | 19 +++++- packages/metadata/src/view-container-name.ts | 61 +++++++++++++------ 6 files changed, 95 insertions(+), 29 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index 0863c4f0c15..bf8c527b617 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -145,10 +145,15 @@ const PROBES: Record }> = // Schema-valid on purpose, like the four above: `field` resolves // `FieldSchema`, so a malformed body would 422 before the registry consult // and the probe would prove nothing about the code-only gate. + // + // [#21470] And NAMELESS on purpose: the row is named `.`, + // which a dot-free `FieldSchema` `name` can never spell, so a body `name` + // would now be refused by the write doors' name judge (`VALIDATION_ERROR` + // / 400) once the hatch below opens the door — a different refusal from + // the one this probe measures. `FieldSchema` does not require `name`. field: { name: 'rc3_field_probe.zz_probe', item: { - name: 'zz_probe', label: 'Probe', type: 'text', }, diff --git a/packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts b/packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts index fbe263a4782..4b8754b944d 100644 --- a/packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts +++ b/packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts @@ -133,6 +133,19 @@ const fieldRow = (type: string): Row => row({ metadata: JSON.stringify({ name: FIELD, label: 'Probe', type }), }); +/** + * [#21470] The body a hatch-path save SENDS: a field definition with no + * `name`. A `field` row is named `.`, which a `FieldSchema` + * `name` (dot-free) can never spell, so every runtime write door now refuses a + * `field` body whose `name` is set (`VALIDATION_ERROR` / 400, before this + * file's gates) — the write would register the row under the column name. + * `FieldSchema` does not require `name`, so the nameless body is the one the + * hatch can carry, and what these cases measure is unchanged: the destructive + * gate's reach, not the body's name. The STORED rows above keep theirs, as a + * row written before that refusal sits at rest. + */ +const HATCH_BODY = (type: 'text' | 'number') => ({ label: 'Probe', type }); + /** An `object` row carrying a real `fields` map — the only diffable shape. */ const objectRow = (name: string, fields: readonly string[]): Row => row({ type: 'object', @@ -364,7 +377,7 @@ describe('[#11014 §2 reason 1] a `field` body has no `fields` map to diff', () const r = await save(protocol, { type: 'field', name: DOTTED, - item: { name: FIELD, label: 'Probe', type: 'number' }, + item: HATCH_BODY('number'), }); // It persisted — so the request reached, and passed, the gate. @@ -379,7 +392,7 @@ describe('[#11014 §2 reason 1] a `field` body has no `fields` map to diff', () const r = await save(protocol, { type: 'field', name: DOTTED, - item: { name: FIELD, label: 'Probe', type: 'number' }, + item: HATCH_BODY('number'), }); expect(r.outcome).toBe('resolved'); @@ -469,7 +482,7 @@ describe('[#11014 §3] the double fault the trimmed limb used to catch', () => { const r = await save(protocol, { type: 'field', name: DOTTED, - item: { name: FIELD, label: 'Probe', type: 'text' }, + item: HATCH_BODY('text'), }); expect(r.outcome).toBe('resolved'); diff --git a/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts b/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts index 4a473fb71bf..3e98e36290e 100644 --- a/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts +++ b/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts @@ -141,9 +141,15 @@ function makeProtocol(seedRows: Array> = []) { const metaRows = (rows: Map) => [...rows.values()].filter((r) => r.type !== undefined); -/** Spec-valid `view` body, so the ONLY variable under test is the NAME. */ +/** + * Spec-valid `view` body, so the ONLY variable under test is the NAME. + * + * [#21470] It carries no `name` of its own: the door stamps the request name + * onto a view body that has none, and a body `name` that differed from the + * request name would now be refused by the write doors' name judge — a second + * variable this suite does not measure. + */ const VIEW_BODY = { - name: 'probe_item', label: 'Probe', object: 'task', viewKind: 'list', diff --git a/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts b/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts index 24aa207fbc7..5d437b088c9 100644 --- a/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts @@ -463,8 +463,10 @@ describe('#16224 — an overlay that REDEFINES a code-package item is judged fro }); /** One metric widget, so the count of findings IS the count of bindings. */ - const oneWidgetBoard = (dataset: string, value: string) => ({ - name: 'p16224_dash', + // [#21470] Named for the row it is saved under: a body `name` that is not + // its row's is refused by the write doors' name judge, before this gate. + const oneWidgetBoard = (dataset: string, value: string, name = 'p16224_dash') => ({ + name, label: 'Overlay smoke dashboard', widgets: [{ id: 'kpi', type: 'metric', title: 'Total', dataset, values: [value] }], }); @@ -577,7 +579,7 @@ describe('#16224 — an overlay that REDEFINES a code-package item is judged fro // The store-only name resolves, with its own measure… const additive = await protocol.saveMetaItem({ - type: 'dashboard', name: 'p2008_dash', item: oneWidgetBoard('p2008_users', 'order_count'), + type: 'dashboard', name: 'p2008_dash', item: oneWidgetBoard('p2008_users', 'order_count', 'p2008_dash'), }); expect(additive.success, '#16223: a store-only name is contributed to the universe').toBe(true); diff --git a/packages/metadata/src/view-container-name.test.ts b/packages/metadata/src/view-container-name.test.ts index da72114c4ee..802b875565c 100644 --- a/packages/metadata/src/view-container-name.test.ts +++ b/packages/metadata/src/view-container-name.test.ts @@ -136,19 +136,32 @@ describe('#21470 — the write-door entry judges every type', () => { const view = savedItemNameRefusal('view', boundBody('lead_views'), 'crm_lead', 'save')!; expect(view.message.endsWith("Register under one name: drop `name`, or set it to 'crm_lead'.")).toBe(true); const other = savedItemNameRefusal('dashboard', dash('dash_b'), 'dash_a', 'save')!; - expect(other.message.endsWith("Register under one name: set `name` to 'dash_a'.")).toBe(true); + expect(other.message.endsWith("Register under one name: set `name` to 'dash_a', or save the item under 'dash_b'.")).toBe(true); expect(other.message).not.toContain('drop `name`'); + // A `name` that is not a name offers only the direction that exists. + const empty = savedItemNameRefusal('translation', { name: '', locale: 'zh-CN' }, 'crm_zh', 'save')!; + expect(empty.message.endsWith("Register under one name: set `name` to 'crm_zh'.")).toBe(true); + }); + + it('a `field` is told to drop `name`: its row is named object.field, which a dot-free column `name` cannot spell', () => { + // The field's canonical body carries its column name under a dotted row; + // registered, the row would answer under the column name alone. + const field = savedItemNameRefusal('field', { name: 'zz_probe', type: 'text' }, 'crm_task.zz_probe', 'save')!; + expectEnvelope(field); + expect(field.message).toContain('Register under one name: drop `name`'); + expect(field.message).not.toContain("set `name` to 'crm_task.zz_probe'"); + expect(savedItemNameRefusal('field', { type: 'text' }, 'crm_task.zz_probe', 'save')).toBeUndefined(); }); it('the restore and publish doors prescribe the save that fixes a body their caller cannot edit', () => { const restored = savedItemNameRefusal('dashboard', dash('dash_b'), 'dash_a', 'restore')!; expectEnvelope(restored); expect(restored.message).toContain('the name it is restored under'); - expect(restored.message).toContain("save 'dash_a' with `name` set to 'dash_a', instead of restoring this version"); + expect(restored.message).toContain("save the item with `name` set to 'dash_a', or under 'dash_b', instead of restoring this version"); const published = savedItemNameRefusal('dashboard', dash('dash_b'), 'dash_a', 'publish')!; expectEnvelope(published); expect(published.message).toContain('the name it is published under'); - expect(published.message).toContain("save the draft again with `name` set to 'dash_a', then publish it"); + expect(published.message).toContain("save the draft again with `name` set to 'dash_a', or under 'dash_b', then publish it"); }); }); diff --git a/packages/metadata/src/view-container-name.ts b/packages/metadata/src/view-container-name.ts index 9d7c6401ab8..5555a333d72 100644 --- a/packages/metadata/src/view-container-name.ts +++ b/packages/metadata/src/view-container-name.ts @@ -84,10 +84,12 @@ * is byte for byte the words the boot loop and `os validate` have always * printed, and the save door's rendering for a view container is byte for byte * the words it printed before every type joined. The remedy is true per door - * and per type: "drop `name`" only where the door stamps a missing name (a - * view at the save door); "set `name`" where the schema requires one; and at - * the restore and publish doors, which write a stored body the caller cannot - * edit, the save that fixes it. The runtime string carries NO tracker id + * and per type (`remediesFor`): "drop `name`" only where that works (a view, + * whose missing name the save door stamps; a `field`, whose row name its + * column `name` cannot spell); "set `name`", or save under the body's own + * name, everywhere else; and at the restore and publish doors, which write a + * stored body the caller cannot edit in place, the save that fixes it. The + * runtime string carries NO tracker id * (`check:doc-authoring`). The envelope is ADR-0112's `VALIDATION_ERROR` / * 400 at every door. * @@ -231,34 +233,59 @@ export function savedItemNameRefusal( if (stampsMissingName ? !isNonEmptyString(name) : name === undefined) return undefined; const container = isView && isAggregatedViewContainer(item); const noun = container ? 'view container' : type; + const owner = container ? CONTAINER_OWNER : 'its'; return judge(name, saveName, (key) => { - const fixedName = isView ? `\`name\` dropped or set to '${key}'` : `\`name\` set to '${key}'`; + const remedies = remediesFor(type, name, key); if (door === 'save') { - return { - subject: noun, - owner: container ? CONTAINER_OWNER : 'its', - key: 'the name it is saved under', - keyDetail: '', - alsoRefused: '', - remedy: isView ? stampedRemedy(key) : `set \`name\` to '${key}'`, - }; + return { subject: noun, owner, key: 'the name it is saved under', keyDetail: '', alsoRefused: '', remedy: remedies.save }; } return door === 'restore' ? { subject: `${noun} version`, - owner: container ? CONTAINER_OWNER : 'its', + owner, key: 'the name it is restored under', keyDetail: '', alsoRefused: '', - remedy: `save '${key}' with ${fixedName}, instead of restoring this version`, + remedy: `save the item ${remedies.saveWith}, instead of restoring this version`, } : { subject: `${noun} draft`, - owner: container ? CONTAINER_OWNER : 'its', + owner, key: 'the name it is published under', keyDetail: '', alsoRefused: '', - remedy: `save the draft again with ${fixedName}, then publish it`, + remedy: `save the draft again ${remedies.saveWith}, then publish it`, }; }); } + +/** + * The fix that is TRUE for this type, said two ways: as the save door's + * remedy, and as the "with …" clause of the save the restore and publish doors + * prescribe (their caller cannot edit a stored version or draft in place). + * + * - `view`: the save door stamps a missing name, so dropping it works — the + * words the container doors always printed. + * - `field`: a `field` row is named `object.field`, and `FieldSchema` keeps + * the column `name` dot-free, so the save name can never be its `name`; + * the schema does not require one, so dropping it is the fix (measured). + * - every other type: set `name` to the save name, or — when the body's own + * `name` is a name at all — save the item under it instead. Both are + * offered because a save name the type's schema cannot spell (a dotted + * name, where the schema forbids the dot) leaves only the second. + */ +function remediesFor(type: string, name: unknown, key: string): { save: string; saveWith: string } { + if (type === 'view') { + return { save: stampedRemedy(key), saveWith: `with \`name\` dropped or set to '${key}'` }; + } + if (type === 'field') { + return { + save: 'drop `name` (a `field` row is named object.field, which its column `name` cannot spell)', + saveWith: 'with `name` dropped', + }; + } + return isNonEmptyString(name) + ? { save: `set \`name\` to '${key}', or save the item under '${name}'`, saveWith: `with \`name\` set to '${key}', or under '${name}'` } + : { save: `set \`name\` to '${key}'`, saveWith: `with \`name\` set to '${key}'` }; +} + From bf22fdae73d48d4fae9f55a2eadcd339f60b7b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:12:54 +0000 Subject: [PATCH 5/7] =?UTF-8?q?wip:=20objectql=20fixture=20triage=20?= =?UTF-8?q?=E2=80=94=20bodies=20named=20for=20the=20row=20they=20are=20sav?= =?UTF-8?q?ed=20under?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../src/protocol-recorded-by-null.test.ts | 7 ++++--- .../src/protocol-save-meta-repo-path.test.ts | 16 +++++++++------- .../publish-meta-response-conformance.test.ts | 3 +++ .../src/save-meta-response-conformance.test.ts | 3 +++ 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/objectql/src/protocol-recorded-by-null.test.ts b/packages/objectql/src/protocol-recorded-by-null.test.ts index db5916e634b..467bb358af6 100644 --- a/packages/objectql/src/protocol-recorded-by-null.test.ts +++ b/packages/objectql/src/protocol-recorded-by-null.test.ts @@ -184,7 +184,8 @@ function makeStubDriver() { return { driver, stores }; } -const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair +// [#21470] `name` is the row's: a body `name` that is not its row's is refused by the write doors. +const viewBody = (label: string, name = 'cases') => ({ name, type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair describe('#4556 — protocol write paths store NULL, not the sentinel string', () => { let engine: ObjectQL; @@ -253,11 +254,11 @@ describe('#4556 — protocol write paths store NULL, not the sentinel string', ( it('no history row on ANY path carries a value that is not a sys_user id', async () => { // The three authoring paths #4441 was bitten by: create, publish, delete. await protocol.saveMetaItem({ - type: 'view', name: 'a', organizationId: 'org_x', item: viewBody('a'), mode: 'draft', + type: 'view', name: 'a', organizationId: 'org_x', item: viewBody('a', 'a'), mode: 'draft', }); await protocol.publishMetaItem({ type: 'view', name: 'a', organizationId: 'org_x' }); await protocol.saveMetaItem({ - type: 'view', name: 'b', organizationId: 'org_x', item: viewBody('b'), actor: 'usr_alice', + type: 'view', name: 'b', organizationId: 'org_x', item: viewBody('b', 'b'), actor: 'usr_alice', }); await protocol.deleteMetaItem({ type: 'view', name: 'b', organizationId: 'org_x' }); diff --git a/packages/objectql/src/protocol-save-meta-repo-path.test.ts b/packages/objectql/src/protocol-save-meta-repo-path.test.ts index 21d16f25091..d34dc28c68a 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path.test.ts @@ -151,16 +151,18 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { expect(row.checksum).toBe(hashSpec(body)); }); + // [#21470] Every view body below is named for the row it is saved under: + // a body `name` that is not its row's is refused by the write doors. it('repository path increments seq across writes and updates the body', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); const r1 = await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', - item: { name: 'view_one', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, + item: { name: 'v', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, }); const r2 = await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', - item: { name: 'view_one', type: 'grid', label: 'B', columns: ['id'], object: 'case', viewKind: 'list' }, + item: { name: 'v', type: 'grid', label: 'B', columns: ['id'], object: 'case', viewKind: 'list' }, }); expect((r1 as any).seq).toBe(1); expect((r2 as any).seq).toBe(2); @@ -174,13 +176,13 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { // First write establishes a HEAD. await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', - item: { name: 'view_one', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, + item: { name: 'v', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, }); // Second write with an explicit stale parentVersion → conflict. await expect( protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', - item: { name: 'view_one', type: 'grid', label: 'B', columns: ['id'], object: 'case', viewKind: 'list' }, + item: { name: 'v', type: 'grid', label: 'B', columns: ['id'], object: 'case', viewKind: 'list' }, parentVersion: 'sha256:notTheCurrentHead', }), ).rejects.toMatchObject({ @@ -192,7 +194,7 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { it('repository path no-ops when body is identical (idempotent put)', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - const body = { name: 'view_one', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }; + const body = { name: 'v', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }; const r1 = await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', item: body, }); @@ -211,11 +213,11 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ type: 'view', name: 'v', - item: { name: 'view_one', type: 'grid', label: 'env-wide', columns: ['id'], object: 'case', viewKind: 'list' }, + item: { name: 'v', type: 'grid', label: 'env-wide', columns: ['id'], object: 'case', viewKind: 'list' }, }); await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org_alpha', - item: { name: 'view_one', type: 'grid', label: 'org_alpha', columns: ['id'], object: 'case', viewKind: 'list' }, + item: { name: 'v', type: 'grid', label: 'org_alpha', columns: ['id'], object: 'case', viewKind: 'list' }, }); // Two rows: one with organization_id=null, one with org_alpha. expect(rows.size).toBe(2); diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index a1396f5a2ff..941b9614a09 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -407,6 +407,9 @@ describe('publishMetaItem carries the runtime authoring gate\'s advisories (#917 const cleanFlow = () => { const flow = advisoryFlow(); (flow.nodes[1] as any).config.filter = [{ field: 'created_at', operator: 'lt', value: '2020-01-01' }]; + // [#21470] Named for the row it is saved under (`bounded_purge`): a + // body `name` that is not its row's is refused by the write doors. + flow.name = 'bounded_purge'; return flow; }; diff --git a/packages/objectql/src/save-meta-response-conformance.test.ts b/packages/objectql/src/save-meta-response-conformance.test.ts index a17c48cc645..86f4357cd77 100644 --- a/packages/objectql/src/save-meta-response-conformance.test.ts +++ b/packages/objectql/src/save-meta-response-conformance.test.ts @@ -292,6 +292,9 @@ describe('saveMetaItem carries the runtime authoring gate\'s advisories (#4717 const cleanFlow = () => { const flow = advisoryFlow(); (flow.nodes[1] as any).config.filter = [{ field: 'created_at', operator: 'lt', value: '2020-01-01' }]; + // [#21470] Named for the row it is saved under (`bounded_purge`): a + // body `name` that is not its row's is refused by the write doors. + flow.name = 'bounded_purge'; return flow; }; From 2f5e7fce83c2bbfcbb476a33c8266d87ae084c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:29:32 +0000 Subject: [PATCH 6/7] wip: changeset remedies match the per-type words; the field hatch line Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../21470-metadata-protocol-every-write-door-item-name.md | 4 ++-- .changeset/21470-metadata-write-door-item-name-judge.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/21470-metadata-protocol-every-write-door-item-name.md b/.changeset/21470-metadata-protocol-every-write-door-item-name.md index 937ada38fc4..d74fbc5771c 100644 --- a/.changeset/21470-metadata-protocol-every-write-door-item-name.md +++ b/.changeset/21470-metadata-protocol-every-write-door-item-name.md @@ -16,6 +16,6 @@ Clause-②: no (narrowing) - `rollbackMetaItem` and `revertCommit`, which wrote such a stored history version back as the active row without passing `saveMetaItem`; - `publishMetaItem` and `publishPackageDrafts`, which promoted such a stored draft the same way. -**What is refused now.** Each of those bodies, with `VALIDATION_ERROR` / 400, before anything is stored or registered, through the judge the view-container refusal already used (`savedItemNameRefusal`, `@objectstack/metadata/view-container-name`). `rollbackMetaItem` and `publishMetaItem` throw it. `revertCommit` reports the item in `failed[]` with `code: 'VALIDATION_ERROR'`. `publishPackageDrafts` aborts the batch on it, as it does on any refused draft: nothing in the batch is published. A body with no `name` is accepted as before. A `name` the body carries is judged whatever its value; a `translation` saved with `name: ''`, which its schema accepts, is now refused instead of being registered under the empty string. A view at the save door is the exception: a missing or empty view `name` is still stamped with the save name. Where the type's schema already refused such a body (an empty or non-string `name` on most types, any `name` on a `seed`, whose schema declares none), the answer is now this refusal (`VALIDATION_ERROR` / 400) instead of the schema's `INVALID_METADATA` / 422; nothing is stored either way. +**What is refused now.** Each of those bodies, with `VALIDATION_ERROR` / 400, before anything is stored or registered, through the judge the view-container refusal already used (`savedItemNameRefusal`, `@objectstack/metadata/view-container-name`). `rollbackMetaItem` and `publishMetaItem` throw it. `revertCommit` reports the item in `failed[]` with `code: 'VALIDATION_ERROR'`. `publishPackageDrafts` aborts the batch on it, as it does on any refused draft: nothing in the batch is published. A body with no `name` is accepted as before. A `name` the body carries is judged whatever its value; a `translation` saved with `name: ''`, which its schema accepts, is now refused instead of being registered under the empty string. A view at the save door is the exception: a missing or empty view `name` is still stamped with the save name. A `field` written through the `OS_METADATA_WRITABLE` operator hatch is accepted only without a body `name`: its row is named `object.field`, which the column `name` cannot spell, and registered it answered under the column name alone. Where the type's schema already refused such a body (an empty or non-string `name` on most types, any `name` on a `seed`, whose schema declares none), the answer is now this refusal (`VALIDATION_ERROR` / 400) instead of the schema's `INVALID_METADATA` / 422; nothing is stored either way. -**The fix.** Set the body's `name` to the name you save it under, or drop it where the type allows (a view's is stamped). To bring back a version or a draft that carries another `name`, save the item again with `name` set to its row's name, and publish that save if it is a draft. +**The fix.** Set the body's `name` to the name you save it under, or save the item under the body's own `name`; for a view or a `field`, dropping `name` works too. To bring back a version or a draft that carries another `name`, save the item again with that fix, and publish that save if it is a draft. diff --git a/.changeset/21470-metadata-write-door-item-name-judge.md b/.changeset/21470-metadata-write-door-item-name-judge.md index 38826833b58..de4b36333ea 100644 --- a/.changeset/21470-metadata-write-door-item-name-judge.md +++ b/.changeset/21470-metadata-write-door-item-name-judge.md @@ -8,5 +8,5 @@ Clause-②: yes - `savedItemNameRefusal(type, item, saveName, door)` is the one entry the runtime write doors of `@objectstack/metadata-protocol` call. It returns a `VALIDATION_ERROR` / 400 refusal when a body of any type carries its own `name` and that `name` differs from the name the row is written under, and `undefined` otherwise. `door` is `'save'`, `'restore'` or `'publish'`. A body with no `name` passes. A `name` the body does carry is judged whatever its value (`''`, `null` and non-strings included), with one exception: a `view` at the `'save'` door, which stamps a missing name there, is judged only on a non-empty string `name`. - It replaces `savedViewContainerNameRefusal(container, saveName)`, which judged view containers only. That export was added to this subpath in this same release cycle and never shipped in a published version, so no published export is removed. -- The words name the type and give a remedy that works for it: "drop `name`, or set it to KEY" only for a view at the save door, "set `name` to KEY" for every other type, and at the restore and publish doors the save that fixes the stored body. For a view container at the save door the message is byte for byte the one `savedViewContainerNameRefusal` returned. +- The words name the type and give a remedy that works for it: "drop `name`, or set it to KEY" for a view, whose missing name the save door stamps; "drop `name`" for a `field`, whose row is named `object.field`, which its dot-free column `name` cannot spell; "set `name` to KEY, or save the item under NAME" for every other type; and at the restore and publish doors, whose caller cannot edit the stored body in place, the save that fixes it. For a view container at the save door the message is byte for byte the one `savedViewContainerNameRefusal` returned. - `viewContainerNameRefusal` (the source registrars' entry), its words and the `ViewContainerNameRefusal` type are unchanged. From 181408e1e59f89ae58c196878c99ca7531401e2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:45:25 +0000 Subject: [PATCH 7/7] wip: the double-contract ledger records the every-door test's pinned engine double Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 30645a03924..4197e8aef36 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -736,6 +736,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.item-name-every-door.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.item-name-every-door.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.item-name-every-door.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.item-name-grammar.test.ts", "verb": "delete",