From c874c646088ab0d832992034ac3473878337f600 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:54:45 +0000 Subject: [PATCH 1/6] fix(metadata-protocol): the active save door refuses an object without its package namespace prefix ADR-0129 D2 holds at both runtime doors that ask the rule: the direct (publish-mode) saveMetaItem now refuses an object whose name lacks the ${namespace}_ prefix of the package it is written under, with the NAMESPACE_PREFIX code and the authored prescription the publish-drafts pre-flight already answers. One judge (objectNamespacePrefixViolation) serves both doors. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- ...tocol.active-save-namespace-prefix.test.ts | 295 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 96 +++++- 2 files changed, 378 insertions(+), 13 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts diff --git a/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts b/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts new file mode 100644 index 00000000000..0d01efd0dcd --- /dev/null +++ b/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts @@ -0,0 +1,295 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +// The producer's OWN write-verb dispatch decisions, so the fake engine below +// cannot accept a call ObjectQL refuses (`check:engine-double-contract`). +// Imported from `@objectstack/metadata-core` and not from +// `@objectstack/objectql`: objectql DEPENDS ON this package. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** + * [#24547 · ADR-0129 D2] The package namespace-prefix rule holds at BOTH + * runtime doors that make an object live. + * + * ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and + * every object it defines MUST be named `${namespace}_${shortName}`. The + * draft-publish door (`publishPackageDrafts`) refused an unprefixed object + * draft with `NAMESPACE_PREFIX`, while the direct (publish-mode) save door + * stored the very same name `active` under the very same package — an object + * live under a name its own package's drafts could never publish again, and + * two runtime doors giving opposite verdicts on one name. + * + * Measured through the wire before the fix (stock showcase): + * `PUT /api/v1/meta/object/v1_report?package=com.v1.rollup` answered 200 + * `state: "active"`, and the same name's draft was refused at + * `POST /api/v1/packages/com.v1.rollup/publish-drafts` with + * `NAMESPACE_PREFIX`. + * + * What these cases pin: + * - the active door refuses the unprefixed name, with the envelope the door + * gives every other authoring refusal (`code` + `status`), the authored + * prescription the publish door serves for the same name, and nothing + * written; + * - the prefixed name saves; + * - a package that declares no namespace, and a save naming no package, are + * untouched (the rule is skipped without a namespace, as `defineStack` + * skips it); + * - a package-less publish save the door resolves INTO a namespaced package + * is judged as a save naming that package; + * - a draft save is unchanged, and the publish door still refuses its draft — + * with the same sentence the active door now answers. + */ + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; +} + +function keyOf(w: Record) { + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +} + +/** Does row `r` satisfy `where` (top-level eq + `$or`)? */ +function matchesMetadataWhere(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) => matchesMetadataWhere(r, c))) return false; + continue; + } + // `undefined` = "dimension not constrained"; `null` = "must be NULL". + if (v === undefined) continue; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const op = v as Record; + if ('$in' in op) { + if (!(op.$in as unknown[]).includes((r as any)[k])) return false; + continue; + } + if ('$ne' in op) { + if ((r as any)[k] === op.$ne) return false; + continue; + } + } + if ((r as any)[k] !== v) return false; + } + return true; +} + +const PKG = 'com.v1.rollup'; +const NS = 'rollup'; +/** A package that declares no namespace — legacy-grandfathered. */ +const BARE_PKG = 'com.v1.bare'; + +function makeStubEngine() { + const rows = new Map(); + let nextId = 0; + + 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; + } + for (const [k, r] of rows) if (matchesMetadataWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const manifests: Record> = { + [PKG]: { id: PKG, namespace: NS }, + [BARE_PKG]: { id: BARE_PKG }, + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record }) { + if (table !== 'sys_metadata') return []; + return Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where ?? {})); + }, + async insert(table: string, data: Record) { + nextId += 1; + if (table !== 'sys_metadata') return { id: `x_${nextId}` }; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(table: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + if (table !== 'sys_metadata') return { deleted: 0 }; + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + getPackage: (id: string) => (manifests[id] ? { manifest: manifests[id] } : undefined), + }, + }; + return { engine, rows }; +} + +const objectBody = (name: string, label = `Object ${name}`) => ({ + name, + label, + // [#8308] Authored OWD — the authoring gate refuses an OWD-less custom object. + sharingModel: 'private', + fields: { + title: { type: 'text', label: 'Title' }, + }, +}); + +/** The stored rows for one object name, as `(state, package_id)` pairs. */ +const rowsFor = (rows: Map, name: string) => + Array.from(rows.values()) + .filter((r) => r.type === 'object' && r.name === name) + .map((r) => ({ state: r.state, package_id: r.package_id })); + +describe('[#24547 · ADR-0129 D2] the active save door refuses an object without its package namespace prefix', () => { + it('refuses an unprefixed object under a namespaced package — NAMESPACE_PREFIX / 422, nothing written', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + const outcome = await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: PKG, + }).then(() => 'saved', (e: unknown) => e); + + expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 }); + // The prescription names the rename and the namespace it comes from. + expect((outcome as Error).message).toMatch(/^Object 'v1_report' is missing the package namespace prefix\. /); + expect((outcome as Error).message).toContain(`Rename it to '${NS}_v1_report'`); + // Refused before any write: no row, active or draft, under any package. + expect(rowsFor(rows, 'v1_report')).toEqual([]); + }); + + it('saves the prefixed name under the same package', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: `${NS}_v1_report`, + item: objectBody(`${NS}_v1_report`), + packageId: PKG, + }); + + expect(rowsFor(rows, `${NS}_v1_report`)).toEqual([{ state: 'active', package_id: PKG }]); + }); + + it('control: a package that declares no namespace saves the unprefixed name', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: BARE_PKG, + }); + + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'active', package_id: BARE_PKG }]); + }); + + it('control: a save naming no package (and resolving to none) saves the unprefixed name', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + }); + + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'active', package_id: null }]); + }); + + it('judges a package-less publish save against the namespaced package it resolves into', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + // An active row bound to the namespaced package already holds the + // name (stored before this door read the rule), so a save naming no + // package writes THAT package's row. + await engine.insert('sys_metadata', { + type: 'object', + name: 'v1_report', + organization_id: null, + package_id: PKG, + state: 'active', + metadata: JSON.stringify(objectBody('v1_report')), + checksum: 'sha256:stored', + version: 1, + }); + + const outcome = await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report', 'Edited'), + }).then(() => 'saved', (e: unknown) => e); + + expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 }); + // The stored row is untouched. + const stored = Array.from(rows.values()).find((r) => r.name === 'v1_report'); + expect(JSON.parse(stored!.metadata).label).toBe('Object v1_report'); + }); + + it('a draft save is unchanged, and the publish door refuses that draft with the sentence the active door answers', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + const activeRefusal = await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: PKG, + }).then(() => undefined, (e: Error) => e); + + // The draft save stores, as before — the publish pre-flight is the + // gate a draft answers to. + await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: PKG, + mode: 'draft', + }); + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]); + + const res = await protocol.publishPackageDrafts({ packageId: PKG } as any); + + expect(res).toMatchObject({ success: false, outcome: 'refused', publishedCount: 0, failedCount: 1 }); + expect(res.failed[0]).toMatchObject({ type: 'object', name: 'v1_report', code: 'NAMESPACE_PREFIX' }); + // ONE rule, ONE sentence: the two doors cannot disagree on the verdict + // or on what the author is told to do about it. + expect(activeRefusal).toBeInstanceOf(Error); + expect(res.failed[0].error).toBe(activeRefusal!.message); + // Nothing went live. + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5b317be9576..72a5cff9503 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22825,6 +22825,29 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#24547 · ADR-0129 D2] The package namespace-prefix verdict on one object + * name: `validateObjectNamespacePrefix`'s own authored prescription when + * `name` lacks the `${namespace}_` prefix of the package it is written + * under, else `null`. + * + * ONE judge for both runtime doors that ask the rule — the publish + * pre-flight of {@link publishPackageDrafts} and the direct (publish-mode) + * save of {@link saveMetaItem} — so they cannot give opposite verdicts on + * one name in one package again, nor tell the author two different things + * about it. + * + * The namespace is the one the package DECLARES: its installed manifest, + * where {@link installPackage} derives and persists one when the author + * wrote none. A package that declares none, and a write bound to no + * package, are not judged — like `defineStack`, nothing here invents a + * prefix (`validateObjectNamespacePrefix` skips an empty namespace). + */ + private objectNamespacePrefixViolation(name: string, packageId: string | null | undefined): string | null { + if (typeof packageId !== 'string' || packageId === '') return null; + return validateObjectNamespacePrefix(name, this.engine?.registry?.getPackage?.(packageId)?.manifest?.namespace); + } + // [#21207] `parentVersion` is a CALLER's version token — the keyed form a // receipt served — and is compared in that form (`storedParentForToken`). // `storedParentVersion` is the in-process twin for a caller that read the @@ -23278,6 +23301,52 @@ export class ObjectStackProtocolImplementation implements } } + // [#24547 · ADR-0129 D2] THE NAMESPACE-PREFIX RULE, on the door that + // makes an object live directly. Every object a package defines MUST + // be named `${namespace}_${shortName}`; the publish pre-flight + // ({@link publishPackageDrafts}) refused an unprefixed object draft + // with `NAMESPACE_PREFIX`, while this save stored the very same name + // `active` under the very same package — an object live under a name + // its own package's drafts could never publish again. Both doors now + // ask one judge ({@link objectNamespacePrefixViolation}) and answer + // its one authored prescription. + // + // Judged against the package this save WRITES: the one it names, or + // the one a package-less publish save resolved to above — the save IS + // a save naming that package from there on. After the D9.9 refusal on + // purpose: an object bound to a package that does not own it is told + // which package does, before it is told how to rename it under the + // wrong one. Before every gate that reads or reshapes the body, so a + // refused name costs no parse and writes nothing. + // + // Asked of a publish-mode save only. A draft is not live: the publish + // pre-flight judges it, batch-atomically and in one report with every + // other violation, which is that door's contract. Not asked of the + // server-stated rewrites of a row this store already holds (`source` + // set: the stored-metadata migration, the stored flow-credential move) + // — they define no object, and refusing one would strand a repair of a + // row stored before this door read the rule. The package copies write + // new rows under their target and are asked like any author: + // `package-duplicate` names every copied object in the target's + // namespace by construction, and `package-unmanaged-import` keeps the + // source's names under the source's namespace, which its plan states + // as the condition this rule admits them on. + // + // `NAMESPACE_PREFIX` / 422: the code the publish pre-flight already + // stamps for this exact violation, registered to this package in the + // ADR-0112 ledger, at the status this method gives every other refusal + // of a body that exists but may not be stored as authored + // (`INVALID_METADATA`, `OBJECT_OVERLAY_PACKAGE_MISMATCH`). + if (singularType === 'object' && mode === 'publish' && request.source === undefined) { + const prefixViolation = this.objectNamespacePrefixViolation(request.name, request.packageId); + if (prefixViolation) { + const err: any = new Error(prefixViolation); + err.code = 'NAMESPACE_PREFIX'; + err.status = 422; + throw err; + } + } + // [ADR-0010 §3.5, Phase 4.3] A `protection` block is refused, on every // type that declares one: only a code package's loader translates it // into a lock, and this door writes environment-authored items, so it @@ -26458,7 +26527,10 @@ export class ObjectStackProtocolImplementation implements // packages are grandfathered); the default is derived+persisted once at // install time (`installPackage`), so real Studio packages always have // one by the time they publish. - const pkgNamespace = this.engine?.registry?.getPackage?.(request.packageId)?.manifest?.namespace; + // + // [#24547] The verdict is {@link objectNamespacePrefixViolation}'s, the + // judge the direct save in {@link saveMetaItem} asks too, so the two + // doors give one verdict and one sentence for one name in one package. /** * Every PRE-FLIGHT refusal of this batch, in one report. * @@ -26482,18 +26554,16 @@ export class ObjectStackProtocolImplementation implements error: string; code: PreflightViolationCode; }> = []; - if (pkgNamespace) { - for (const d of drafts) { - if (d.type !== 'object') continue; - const err = validateObjectNamespacePrefix(d.name, pkgNamespace); - if (err) { - preflightViolations.push({ - type: d.type, - name: d.name, - error: err, - code: 'NAMESPACE_PREFIX', - }); - } + for (const d of drafts) { + if (d.type !== 'object') continue; + const err = this.objectNamespacePrefixViolation(d.name, request.packageId); + if (err) { + preflightViolations.push({ + type: d.type, + name: d.name, + error: err, + code: 'NAMESPACE_PREFIX', + }); } } From 8fe98f7c80a9988a6f509dcf13920aa5704ac783 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:15:24 +0000 Subject: [PATCH 2/6] test(objectql): stage the duplicate fixtures' unprefixed objects as rows stored before the save door read the prefix rule The direct save now refuses an object whose name lacks its package's namespace prefix (ADR-0129 D2), so the ADR-0070 D4 duplicate fixtures stage those objects while the package declares no namespace and put the derived one back, keeping what each case pins. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- ...ol-duplicate-package-names-in-step.test.ts | 22 +++++++++++++-- ...cate-package-permission-field-keys.test.ts | 20 +++++++++++++- ...uplicate-package-unprefixed-object.test.ts | 27 ++++++++++++++++--- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts b/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts index 68b7be398fe..e4f0c8a7540 100644 --- a/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts @@ -232,15 +232,33 @@ describe('ADR-0070 D4 — duplicatePackage keeps a copied name in step with what expect(res.success).toBe(true); } + /** + * [#24547] Store an object the direct save now refuses: ADR-0129 D2 holds + * at that door too, so an object whose name lacks its package's prefix is + * a row stored before it read the rule. Staged while the package's + * installed manifest declares no namespace, then the derived one is put + * back, so every read after it sees the package exactly as installed. + */ + async function saveStoredBeforeRule(packageId: string, type: string, item: { name: string }): Promise { + const manifest = engine.registry.getPackage(packageId)!.manifest as { namespace?: string }; + const namespace = manifest.namespace; + delete manifest.namespace; + try { + await save(packageId, type, item); + } finally { + manifest.namespace = namespace; + } + } + /** Seeds the third package and the source, then duplicates the source. */ async function duplicate(): Promise { await protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'app' } }); await save(R, 'object', accountBody); - await save(R, 'object', extBody); + await saveStoredBeforeRule(R, 'object', extBody); await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save(P, 'object', customerBody); - await save(P, 'object', ticketBody); + await saveStoredBeforeRule(P, 'object', ticketBody); await save(P, 'object', noteBody); await save(P, 'view', viewItem('ticket', 'board', 'list', grid('ticket', ['title']))); await save(P, 'view', viewItem('dupsrc_customer', 'board', 'list', grid('dupsrc_customer', ['full_name', 'dupsrc_customer', 'x_dupsrc_customer']))); diff --git a/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts index 60ead6c2647..a1cf3694135 100644 --- a/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts @@ -198,6 +198,24 @@ describe('ADR-0070 D4 — duplicatePackage re-points a copied permission set\'s expect(res.success).toBe(true); } + /** + * [#24547] Store an object the direct save now refuses: ADR-0129 D2 holds + * at that door too, so a bare name under a namespaced package is a row + * stored before it read the rule. Staged while the package's installed + * manifest declares no namespace, then the derived one is put back, so + * every read after it sees the package exactly as installed. + */ + async function saveStoredBeforeRule(packageId: string, type: string, item: { name: string }): Promise { + const manifest = engine.registry.getPackage(packageId)!.manifest as { namespace?: string }; + const namespace = manifest.namespace; + delete manifest.namespace; + try { + await save(packageId, type, item); + } finally { + manifest.namespace = namespace; + } + } + /** Seeds the source and the third package, duplicates, and returns the copy's `fields`. */ async function duplicate(): Promise> { await protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'app' } }); @@ -205,7 +223,7 @@ describe('ADR-0070 D4 — duplicatePackage re-points a copied permission set\'s await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save(P, 'object', customerBody); - await save(P, 'object', ticketBody); + await saveStoredBeforeRule(P, 'object', ticketBody); await save(P, 'permission', flsBody); const res = await protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); diff --git a/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts index e033e2d3be8..f085fc65624 100644 --- a/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts @@ -9,7 +9,8 @@ * * The method renamed an object only when its name started with the source * prefix (`dupsrc_customer` → `dupdst_customer`). A name without it (`ticket`, - * stored by a direct-publish save, which no prefix gate reads) kept its name, + * stored by a direct-publish save before that door read the prefix rule — + * #24547 — or under a package that had no namespace) kept its name, * and an object name IS its registry key and its table: the copy was a second * claim on the source's object. It used to store a target row the registry * refused, over the source's table; since the ADR-0029 D9.9 refusal landed it @@ -195,18 +196,38 @@ describe('ADR-0070 D4 — duplicatePackage of an object whose name lacks the sou expect(res.success).toBe(true); } + /** + * [#24547] Store an object the direct save now refuses: ADR-0129 D2 holds + * at that door too, so a bare name under a namespaced package is a row + * stored before it read the rule. Staged while the package's installed + * manifest declares no namespace, then the derived one is put back, so + * every read after it sees the package exactly as installed. + */ + async function saveStoredBeforeRule(type: string, item: { name: string }): Promise { + const manifest = engine.registry.getPackage(P)!.manifest as { namespace?: string }; + const namespace = manifest.namespace; + delete manifest.namespace; + try { + await save(type, item); + } finally { + manifest.namespace = namespace; + } + } + async function seedSource(extra: Array<[string, { name: string }]> = []): Promise { await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save('object', customerBody); - await save('object', ticketBody); + await saveStoredBeforeRule('object', ticketBody); await save('object', noteBody); await save('view', ticketViewsBody); await save('permission', opsBody); await save('flow', onTicketBody); for (const [type, item] of extra) await save(type, item); - // CONTROL: the bare name was stored by a direct save, no prefix gate read it. + // CONTROL: the bare name is stored, owned by the source, under the + // source's namespace — the row the copy has to rename. expect(owner('ticket')).toBe(P); + expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); } it('copies a prefixed and a non-prefixed object, each into the target namespace', async () => { From ffe3451ccf1deb5fcdce1decc670b912fce24e7b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:18:29 +0000 Subject: [PATCH 3/6] chore(changeset): the direct metadata save refuses an object without its package namespace prefix Also corrects the duplicatePackage comment that named the direct save as a door no prefix gate reads. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- .../24547-active-save-namespace-prefix.md | 25 +++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 6 ++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 .changeset/24547-active-save-namespace-prefix.md diff --git a/.changeset/24547-active-save-namespace-prefix.md b/.changeset/24547-active-save-namespace-prefix.md new file mode 100644 index 00000000000..c36da7e3ff8 --- /dev/null +++ b/.changeset/24547-active-save-namespace-prefix.md @@ -0,0 +1,25 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: the direct metadata save refuses an object whose name lacks its package's namespace prefix, as the package draft publish already does + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing on the metadata save door), shipped as `minor` under the launch-window convention for breaking changes. + +ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and every object it defines is named `${namespace}_${shortName}`. The package draft publish (`POST /api/v1/packages/:id/publish-drafts`) refused an object draft without that prefix with `NAMESPACE_PREFIX`, while a direct save of the very same name under the very same package was stored `active` and went live. That left an object whose own package could never publish a draft of it again, and two doors giving opposite verdicts on one name. + +- FROM: a publish-mode object save bound to a package that declares a namespace answered `200` and stored the object `active` when its name lacked the `${namespace}_` prefix. This covers `PUT /api/v1/meta/object/:name?package=:id` (and `saveMetaItem` in publish mode), including a save naming no package that the door writes into such a package because the object's stored rows are bound to it. +- TO: it answers `422`, code `NAMESPACE_PREFIX`, with the sentence the draft publish gives for the same name, for example "Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup')." Nothing is written. +- Fix: name the object `${namespace}_${shortName}` (here `rollup_v1_report`), as `os validate`, `os build` and the draft publish already require. A package's namespace is the one it declares, or the one derived from its id when it was installed without one (`com.v1.rollup` is `rollup`). + +**Unchanged.** + +- A package that declares no namespace, and a save bound to no package, are not judged. +- A draft save (`mode=draft`) stores as before; the draft publish judges the draft, as before, now with the same sentence the direct save gives. +- `sys_*` names are exempt, as everywhere the rule runs. +- Stored rows are not migrated and not refused on read. The next direct save of a stored object whose name lacks its package's prefix is refused. +- Not judged by this change: the promotion of a single draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`). diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 72a5cff9503..cf5a3b42b37 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -28059,9 +28059,9 @@ export class ObjectStackProtocolImplementation implements // `${namespace}_${name}`". The copy is a NEW object, so that rename // costs nothing here — while renaming the SOURCE object would move its // table. A prefixed name swaps the source prefix for the target's; - // a name WITHOUT the source prefix (a direct-publish save, which no - // prefix gate reads, or a package that had no namespace) takes the - // target prefix in front of its whole name. + // a name WITHOUT the source prefix (a direct-publish save stored + // before that door read the prefix rule — #24547 — or a package that + // had no namespace) takes the target prefix in front of its whole name. // // ⛔ REFUSED, NEVER RE-INVENTED: when that name is already claimed — by // the copy of a prefixed sibling (`ticket` and `iojn_ticket` both name From d5f3ea485481ca85e65b90aa6912e6706777050b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:27:57 +0000 Subject: [PATCH 4/6] chore(engine-double-contract): record the active-save namespace-prefix suite's pinned engine double Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD 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 a88bcd590e9..1a465259921 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -251,6 +251,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts", "verb": "delete", From 77550925f8ada128a840084dcd2e14e8aad319c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:29:43 +0000 Subject: [PATCH 5/6] test(metadata-protocol): the namespace-prefix suite's find double holds the caller's bound Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- .../src/protocol.active-save-namespace-prefix.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts b/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts index 0d01efd0dcd..56a6e39afce 100644 --- a/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts +++ b/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts @@ -112,9 +112,11 @@ function makeStubEngine() { if (table !== 'sys_metadata') return null; return findRow(opts.where)?.row ?? null; }, - async find(table: string, opts: { where: Record }) { + async find(table: string, opts: { where: Record; limit?: number }) { if (table !== 'sys_metadata') return []; - return Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where ?? {})); + const matched = Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where ?? {})); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; }, async insert(table: string, data: Record) { nextId += 1; From afb7f1744793bb0d074a2a0163600eef3fcaa5c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 00:47:57 +0000 Subject: [PATCH 6/6] fix(metadata-protocol): the per-item publish refuses an object without its package namespace prefix The third door that makes an object live under a package now asks the same judge as the direct save and the publish-drafts pre-flight, on the package the promotion writes under, and refuses with NAMESPACE_PREFIX / 422 and the same sentence before anything is promoted. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude --- .../24547-active-save-namespace-prefix.md | 23 +++--- ...tocol.active-save-namespace-prefix.test.ts | 71 +++++++++++++++++-- packages/metadata-protocol/src/protocol.ts | 58 +++++++++++---- 3 files changed, 122 insertions(+), 30 deletions(-) diff --git a/.changeset/24547-active-save-namespace-prefix.md b/.changeset/24547-active-save-namespace-prefix.md index c36da7e3ff8..32cba5cc3f2 100644 --- a/.changeset/24547-active-save-namespace-prefix.md +++ b/.changeset/24547-active-save-namespace-prefix.md @@ -2,24 +2,27 @@ '@objectstack/metadata-protocol': minor --- -fix(metadata-protocol)!: the direct metadata save refuses an object whose name lacks its package's namespace prefix, as the package draft publish already does +fix(metadata-protocol)!: the direct metadata save and the per-item publish refuse an object whose name lacks its package's namespace prefix, as the package draft publish already does Clause-②: no (narrowing) - + -**BREAKING** (an accept-set narrowing on the metadata save door), shipped as `minor` under the launch-window convention for breaking changes. +**BREAKING** (an accept-set narrowing on the metadata save and publish doors), shipped as `minor` under the launch-window convention for breaking changes. -ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and every object it defines is named `${namespace}_${shortName}`. The package draft publish (`POST /api/v1/packages/:id/publish-drafts`) refused an object draft without that prefix with `NAMESPACE_PREFIX`, while a direct save of the very same name under the very same package was stored `active` and went live. That left an object whose own package could never publish a draft of it again, and two doors giving opposite verdicts on one name. +ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and every object it defines is named `${namespace}_${shortName}`. The package draft publish (`POST /api/v1/packages/:id/publish-drafts`) refused an object draft without that prefix with `NAMESPACE_PREFIX`. Two other doors made that very same name `active` under the very same package: a direct save, and the publish of the single draft. That left an object whose own package could never publish a draft of it again, and three doors giving opposite verdicts on one name. -- FROM: a publish-mode object save bound to a package that declares a namespace answered `200` and stored the object `active` when its name lacked the `${namespace}_` prefix. This covers `PUT /api/v1/meta/object/:name?package=:id` (and `saveMetaItem` in publish mode), including a save naming no package that the door writes into such a package because the object's stored rows are bound to it. -- TO: it answers `422`, code `NAMESPACE_PREFIX`, with the sentence the draft publish gives for the same name, for example "Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup')." Nothing is written. +- FROM: an object made `active` under a package that declares a namespace was accepted with `200` when its name lacked the `${namespace}_` prefix. Two doors did this: + - a publish-mode save: `PUT /api/v1/meta/object/:name?package=:id` (and `saveMetaItem` in publish mode), including a save naming no package that the door writes into such a package because the object's stored rows are bound to it; + - the promotion of a single draft: `POST /api/v1/meta/object/:name/publish?package=:id` (and `publishMetaItem`), including a publish naming no package whose draft the door promotes into such a package. +- TO: both doors answer `422`, code `NAMESPACE_PREFIX`. The sentence is the one the draft publish gives for the same name, for example "Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup')." + - The save writes nothing. + - The publish promotes nothing, and the draft stays a draft. - Fix: name the object `${namespace}_${shortName}` (here `rollup_v1_report`), as `os validate`, `os build` and the draft publish already require. A package's namespace is the one it declares, or the one derived from its id when it was installed without one (`com.v1.rollup` is `rollup`). **Unchanged.** -- A package that declares no namespace, and a save bound to no package, are not judged. -- A draft save (`mode=draft`) stores as before; the draft publish judges the draft, as before, now with the same sentence the direct save gives. +- A package that declares no namespace, and a save or publish bound to no package, are not judged. +- A draft save (`mode=draft`) stores as before. The door that promotes the draft judges it, now with one sentence on all three doors. - `sys_*` names are exempt, as everywhere the rule runs. -- Stored rows are not migrated and not refused on read. The next direct save of a stored object whose name lacks its package's prefix is refused. -- Not judged by this change: the promotion of a single draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`). +- Stored rows are not migrated and not refused on read. The next direct save or single-draft publish of a stored object whose name lacks its package's prefix is refused. diff --git a/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts b/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts index 56a6e39afce..d65505f8a66 100644 --- a/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts +++ b/packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts @@ -9,8 +9,9 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFin import { ObjectStackProtocolImplementation } from './protocol.js'; /** - * [#24547 · ADR-0129 D2] The package namespace-prefix rule holds at BOTH - * runtime doors that make an object live. + * [#24547 · ADR-0129 D2] The package namespace-prefix rule holds at ALL THREE + * runtime doors that make an object live under a package: the direct save, + * the package draft publish, and the per-item publish. * * ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and * every object it defines MUST be named `${namespace}_${shortName}`. The @@ -38,7 +39,10 @@ import { ObjectStackProtocolImplementation } from './protocol.js'; * - a package-less publish save the door resolves INTO a namespaced package * is judged as a save naming that package; * - a draft save is unchanged, and the publish door still refuses its draft — - * with the same sentence the active door now answers. + * with the same sentence the active door now answers; + * - the per-item publish (`POST /meta/object/:name/publish`), which promoted + * that draft `active` as well, refuses it with the same envelope and + * sentence and promotes nothing, while a prefixed draft still publishes. */ interface Row { @@ -272,8 +276,8 @@ describe('[#24547 · ADR-0129 D2] the active save door refuses an object without packageId: PKG, }).then(() => undefined, (e: Error) => e); - // The draft save stores, as before — the publish pre-flight is the - // gate a draft answers to. + // The draft save stores, as before — the door that promotes a draft + // is the gate it answers to. await protocol.saveMetaItem({ type: 'object', name: 'v1_report', @@ -284,14 +288,67 @@ describe('[#24547 · ADR-0129 D2] the active save door refuses an object without expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]); const res = await protocol.publishPackageDrafts({ packageId: PKG } as any); + const itemRefusal = await protocol.publishMetaItem({ + type: 'object', + name: 'v1_report', + packageId: PKG, + }).then(() => undefined, (e: Error) => e); expect(res).toMatchObject({ success: false, outcome: 'refused', publishedCount: 0, failedCount: 1 }); expect(res.failed[0]).toMatchObject({ type: 'object', name: 'v1_report', code: 'NAMESPACE_PREFIX' }); - // ONE rule, ONE sentence: the two doors cannot disagree on the verdict - // or on what the author is told to do about it. + // ONE rule, ONE sentence: the three doors cannot disagree on the + // verdict or on what the author is told to do about it. expect(activeRefusal).toBeInstanceOf(Error); + expect(itemRefusal).toBeInstanceOf(Error); expect(res.failed[0].error).toBe(activeRefusal!.message); + expect(itemRefusal!.message).toBe(activeRefusal!.message); // Nothing went live. expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]); }); + + it('the per-item publish refuses an unprefixed object draft under a namespaced package — NAMESPACE_PREFIX / 422, the draft stays a draft', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await protocol.saveMetaItem({ + type: 'object', + name: 'v1_report', + item: objectBody('v1_report'), + packageId: PKG, + mode: 'draft', + }); + + const outcome = await protocol.publishMetaItem({ + type: 'object', + name: 'v1_report', + packageId: PKG, + }).then(() => 'published', (e: unknown) => e); + + expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 }); + expect((outcome as Error).message).toMatch(/^Object 'v1_report' is missing the package namespace prefix\. /); + expect((outcome as Error).message).toContain(`Rename it to '${NS}_v1_report'`); + // Nothing promoted: no active row, the draft untouched. + expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]); + }); + + it('control: the per-item publish promotes a prefixed object draft under the same package', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await protocol.saveMetaItem({ + type: 'object', + name: `${NS}_v1_report`, + item: objectBody(`${NS}_v1_report`), + packageId: PKG, + mode: 'draft', + }); + + const res = await protocol.publishMetaItem({ + type: 'object', + name: `${NS}_v1_report`, + packageId: PKG, + }); + + expect(res.success).toBe(true); + expect(rowsFor(rows, `${NS}_v1_report`)).toContainEqual({ state: 'active', package_id: PKG }); + expect(rowsFor(rows, `${NS}_v1_report`)).not.toContainEqual({ state: 'draft', package_id: PKG }); + }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d600fc2d9e4..aecdeed0e1a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22900,11 +22900,13 @@ export class ObjectStackProtocolImplementation implements * `name` lacks the `${namespace}_` prefix of the package it is written * under, else `null`. * - * ONE judge for both runtime doors that ask the rule — the publish - * pre-flight of {@link publishPackageDrafts} and the direct (publish-mode) - * save of {@link saveMetaItem} — so they cannot give opposite verdicts on - * one name in one package again, nor tell the author two different things - * about it. + * ONE judge for the three runtime doors that make an object live under a + * package — the publish pre-flight of {@link publishPackageDrafts}, the + * direct (publish-mode) save of {@link saveMetaItem}, and the per-item + * publish of {@link publishMetaItem} (asked in + * {@link promoteDraftForPublish}, on the package the promotion writes + * under) — so they cannot give opposite verdicts on one name in one + * package again, nor tell the author different things about it. * * The namespace is the one the package DECLARES: its installed manifest, * where {@link installPackage} derives and persists one when the author @@ -23376,9 +23378,12 @@ export class ObjectStackProtocolImplementation implements // ({@link publishPackageDrafts}) refused an unprefixed object draft // with `NAMESPACE_PREFIX`, while this save stored the very same name // `active` under the very same package — an object live under a name - // its own package's drafts could never publish again. Both doors now - // ask one judge ({@link objectNamespacePrefixViolation}) and answer - // its one authored prescription. + // its own package's drafts could never publish again. The three doors + // that make an object live under a package — this save, the publish + // pre-flight and the per-item publish ({@link publishMetaItem}, asked + // in {@link promoteDraftForPublish}) — ask one judge + // ({@link objectNamespacePrefixViolation}) and answer its one authored + // prescription. // // Judged against the package this save WRITES: the one it names, or // the one a package-less publish save resolved to above — the save IS @@ -23388,9 +23393,10 @@ export class ObjectStackProtocolImplementation implements // wrong one. Before every gate that reads or reshapes the body, so a // refused name costs no parse and writes nothing. // - // Asked of a publish-mode save only. A draft is not live: the publish - // pre-flight judges it, batch-atomically and in one report with every - // other violation, which is that door's contract. Not asked of the + // Asked of a publish-mode save only. A draft is not live: whichever + // door promotes it judges it — the publish pre-flight batch-atomically + // and in one report with every other violation, the per-item publish + // before its promotion writes. Not asked of the // server-stated rewrites of a row this store already holds (`source` // set: the stored-metadata migration, the stored flow-credential move) // — they define no object, and refusing one would strand a repair of a @@ -25618,6 +25624,31 @@ export class ObjectStackProtocolImplementation implements ); } } + // [#24547 · ADR-0129 D2] …and the namespace-prefix rule, the third + // door's turn: the per-item publish (`POST /meta/object/:name/publish`) + // made an object `active` under a namespaced package although its name + // lacks the `${namespace}_` prefix — the name the direct save and the + // publish pre-flight refuse. Asked with their judge + // ({@link objectNamespacePrefixViolation}) and their envelope + // (`NAMESPACE_PREFIX` / 422, the judge's sentence), on `promoteInto` — + // the package the active row is written under, as the D9.9 refusal + // above is — and before `repo.promoteDraft` writes, so nothing is + // promoted and the draft stays a draft. A promotion into no package, + // or into a package that declares no namespace, is not judged; nor is + // sealed managed content, for the D9.9 block's reason (its store + // refusal is that row's one answer). `publishPackageDrafts` also + // promotes through here, but its pre-flight has refused the same name + // with the same judge before any promotion runs, so this answers the + // per-item publish alone. + if (draftForGate && singularType === 'object' && !this.isSealedManagedItem(singularType, request.name)) { + const prefixViolation = this.objectNamespacePrefixViolation(request.name, promoteInto); + if (prefixViolation) { + const err: any = new Error(prefixViolation); + err.code = 'NAMESPACE_PREFIX'; + err.status = 422; + throw err; + } + } // [#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 @@ -26598,8 +26629,9 @@ export class ObjectStackProtocolImplementation implements // one by the time they publish. // // [#24547] The verdict is {@link objectNamespacePrefixViolation}'s, the - // judge the direct save in {@link saveMetaItem} asks too, so the two - // doors give one verdict and one sentence for one name in one package. + // judge the direct save in {@link saveMetaItem} and the per-item + // publish ({@link promoteDraftForPublish}) ask too, so the three doors + // give one verdict and one sentence for one name in one package. /** * Every PRE-FLIGHT refusal of this batch, in one report. *