Skip to content
Merged
28 changes: 28 additions & 0 deletions .changeset/24547-active-save-namespace-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/metadata-protocol': minor
---

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)

<!-- adr-0087: not-required (no-migration-prescription) a refusal at the direct (publish-mode) metadata save door and at the per-item publish door of an object name that the package draft publish pre-flight, `os validate` and `os build` already refuse under ADR-0129 D2: no authorable key, spelling, export, error code or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose name lacks its package's prefix keeps loading and serving; the repair is a new object under the prefixed name, which moves its table and so is the author's decision, not something a ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these doors (not already-registered); and the change is a door verdict, not a declaration or an export (not runtime-interface-only or type-surface-only). -->

**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`. 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: 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 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 or single-draft publish of a stored object whose name lacks its package's prefix is refused.
Original file line number Diff line number Diff line change
@@ -0,0 +1,354 @@
// 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 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
* 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;
* - 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 {
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<string, unknown>) {
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<string, unknown>): boolean {
for (const [k, v] of Object.entries(where)) {
if (k === '$or') {
const clauses = v as Array<Record<string, unknown>>;
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<string, unknown>;
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<string, Row>();
let nextId = 0;

const findRow = (w: Record<string, unknown>): { 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<string, Record<string, unknown>> = {
[PKG]: { id: PKG, namespace: NS },
[BARE_PKG]: { id: BARE_PKG },
};

const engine: any = {
async findOne(table: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(table, opts);
if (table !== 'sys_metadata') return null;
return findRow(opts.where)?.row ?? null;
},
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
if (table !== 'sys_metadata') return [];
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<string, unknown>) {
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<string, unknown>, opts: { where: Record<string, unknown> }) {
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<string, unknown> }) {
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<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
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<string, Row>, 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 door that promotes a draft
// is the gate it 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);
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 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 });
});
});
Loading
Loading