Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/31670-namespace-co-owner-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/objectql': minor
'@objectstack/cli': minor
---

feat(objectql)!: inside one namespace, a sharing rule, queue, assignment rule, hook, job, app, page, dashboard, report, book, analytics cube or tool has one package holder — a package declaring one under a name another package of its namespace already holds is refused at install, `os validate` and `os build`, naming every holder.

Clause-②: no (narrowing) — the package door (`SchemaRegistry.installPackage`, every install, boot included) refuses a package registration it accepted before: one whose item of these twelve types bears a name another installed package of the same namespace holds; `os validate` and `os build` refuse the artifact that would carry it. Nothing is widened.
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` does not teach that two packages of one namespace may share one of these names; the rule ships in the refusal text and in ADR-0150 D2 (child N), which already records it.

<!-- adr-0087: not-required (no-migration-prescription) the refusal removes no key, export or field and changes the shape of no stored body; what an author does about a refused name is pick a different one for one of the two items, which no conversion can choose for them -->

**BREAKING** accept-set narrowing at the package registration door, for twelve types: sharing rule, queue, assignment rule, hook, job, app, page, dashboard, report, book, analytics cube and tool — ADR-0150 D2, ruled (record 6724570466), child N of its execution plan. Measured first-party: 0 such names on a booted showcase (29 packages, 60 declared names) and in the four example apps.

- **Why.** ADR-0150 addresses behavioural metadata by namespace and name (a URL segment, the AI face's flat name), and several packages may share one namespace (one artifact's packages, a package and the packages that declare it). The address `crm/home` names one item only if no two packages of `crm` both hold a page `home`. ADR-0130 D3 already refuses that for objects; this extends it to the twelve bucket 2/3 types phase 1's one-holder rule does not cover.
- **Before.** Two packages of one namespace could each ship, say, a page `home`; every bare read served the first-registered one, silently.
- **Now.** The second package's install is refused ahead of every mutation, so it leaves no record behind. The envelope is the namespace gate's: `code: 'NAMESPACE_CONFLICT'`, `status: 422`, every conflicting name in one refusal, every holder named, `conflicts[]` structured. `os validate` and `os build` give the same sentence for an artifact that carries the clash, and `os build` writes no artifact. A deployment whose installed packages of one namespace already share such a name no longer boots: the boot names both holders.
- **Who moves.** An author whose artifact ships two packages of one namespace holding one such name, or whose package declares a name another installed package of its namespace already holds. **The one-line fix: rename the item in one of the two packages.** A reference that named the old name must name the new one; nothing rewrites stored references.
- **Unchanged.** The same name in two different namespaces. The same name across two different types. The same package registering its own item again, or declaring one name twice in itself. A package with no namespace, and the shareable platform namespaces (`base`, `system`, `sys`) the namespace gate lets packages share. An environment item under a package's name (the ADR-0005 overlay). Every type outside the twelve, and phase 1's one-holder rule. `OS_METADATA_COLLISION=warn` does not downgrade the refusal.
25 changes: 25 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-s
// [#20393] The boot registrar's divergent view-container `name` refusal — the
// walk `os validate` step 2c runs, over `@objectstack/objectql`'s one judge.
import { findViewContainerNameRefusals } from '../utils/view-container-names.js';
// [ADR-0150 D2, child N] The package door's namespace name refusal — the same
// call `os validate` makes at its step 2c-bis.
import { findNamespaceNameRefusals } from '../utils/namespace-co-owner-names.js';
// A field `picklist` that names no picklist the stack declares — the walk
// `os validate` step 2d runs, over the same judge.
import { judgePicklistReferences, picklistRefusalHeading, printPicklistReferenceNotices } from '../utils/picklist-references.js';
Expand Down Expand Up @@ -513,6 +516,28 @@ export default class Compile extends Command {
this.exit(1);
}

// 3a-ter. [ADR-0150 D2, child N] A package of this artifact holding a
// name of the twelve uncovered bucket 2/3 types that another package
// of its namespace holds is REFUSED, naming every holder — the SAME
// call `os validate` makes at its step 2c-bis, so the artifact this
// door writes is one the package door installs. Right after the
// parse and ahead of every artifact write, like 3a, with 3a's faces.
const namespaceNameRefusals = findNamespaceNameRefusals(result.data as Record<string, unknown>);
if (namespaceNameRefusals.length > 0) {
if (flags.json) {
await emitJson({ success: false, errors: namespaceNameRefusals, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
this.exit(1);
}
const n = namespaceNameRefusals.length;
console.log('');
printError(`The server would refuse this stack at boot (${n} package${n > 1 ? 's' : ''} holding a name another package of its namespace holds)`);
printBulletList(
namespaceNameRefusals.map((r) => r.message),
{ noun: 'namespace name refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
);
this.exit(1);
}

// 3a-bis. A field `picklist`, or a `picklistExtensions` entry's `extend`,
// that names no picklist the stack declares is REFUSED — the SAME call
// `os validate` makes at its step 2d, so the two doors cannot disagree
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-s
// over the parsed stack the way the load path registers it. The verdict is
// `@objectstack/objectql`'s; see the module header.
import { findViewContainerNameRefusals } from '../utils/view-container-names.js';
// [ADR-0150 D2, child N] Inside one namespace, an item of the twelve uncovered
// bucket 2/3 types has one package holder: the package door's predicate and
// sentence, walked over the artifact's packages in boot order.
import { findNamespaceNameRefusals } from '../utils/namespace-co-owner-names.js';
// [ADR-0146 D2, #28410] The override write gate's author-time half: the publish
// door's own judge, walked over the artifact's packages.
import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js';
Expand Down Expand Up @@ -505,6 +509,40 @@ export default class Validate extends Command {
this.exit(1);
}

// 2c-bis. [ADR-0150 D2, child N] A package of this artifact declaring a
// sharing rule, queue, assignment rule, hook, job, app, page,
// dashboard, report, book, analytics cube or tool under a name
// another package of its namespace already declares is REFUSED,
// naming every holder — the package door
// (`SchemaRegistry.installPackage`) refuses that install at boot.
//
// One judge: the predicate and the sentence are
// `@objectstack/objectql`'s; the helper owns only the walk (the
// artifact's packages, in the order the boot installs them). Right
// after the parse, beside 2c and for its reason: the runtime's own
// accept set. `os build` makes the same call.
const namespaceNameRefusals = findNamespaceNameRefusals(result.data as Record<string, unknown>);
if (namespaceNameRefusals.length > 0) {
if (flags.json) {
await emitJson({
valid: false,
errors: namespaceNameRefusals,
warnings: warningsSoFar(),
conversions: conversionNotices,
duration: timer.elapsed(),
});
this.exit(1);
}
const n = namespaceNameRefusals.length;
console.log('');
printError(`The server would refuse this stack at boot (${n} package${n > 1 ? 's' : ''} holding a name another package of its namespace holds)`);
printBulletList(
namespaceNameRefusals.map((r) => r.message),
{ noun: 'namespace name refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
);
this.exit(1);
}

// 2d. A field `picklist`, or a `picklistExtensions` entry's `extend`, that
// names no picklist the stack declares is REFUSED, naming the field
// (or the extension) and the list. `FieldSchema` and
Expand Down
151 changes: 151 additions & 0 deletions packages/cli/src/utils/namespace-co-owner-names.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0150 D2, child N] `findNamespaceNameRefusals` — the WALK `os validate`
* and `os build` run the package door's namespace name predicate over.
*
* The predicate and its sentence are `@objectstack/objectql`'s
* (`namespaceNameConflicts`, `namespaceNameConflictMessage`), the pair the
* package door throws the answer of; its own file pins them. What this module
* owns, and what is pinned here, is which packages it hands that predicate and
* in which order — the boot's: an artifact's `packages[]` bodies, in
* dependency order, each against the bodies before it.
*
* Every row's message is asserted EQUAL to the predicate's sentence for the
* same conflicts — never re-spelled.
*/

import { describe, expect, it } from 'vitest';
import {
NAMESPACE_CONFLICT_CODE,
NAMESPACE_UNIQUE_TYPES,
namespaceNameConflictMessage,
type NamespaceUniqueType,
} from '@objectstack/objectql';
import { findNamespaceNameRefusals } from './namespace-co-owner-names.js';

const COLLECTION: Record<NamespaceUniqueType, string> = {
sharing_rule: 'sharingRules',
queue: 'queues',
assignment_rule: 'assignmentRules',
hook: 'hooks',
job: 'jobs',
app: 'apps',
page: 'pages',
dashboard: 'dashboards',
report: 'reports',
book: 'books',
analytics_cube: 'analyticsCubes',
tool: 'tools',
};

/**
* A spec-valid item of each type: the walk reads packages through
* `resolveArtifactPackageOrder`, which parses every entry as the boot does.
*/
const item = (type: NamespaceUniqueType, name: string): Record<string, unknown> => {
switch (type) {
case 'sharing_rule':
return { name, label: name, type: 'criteria', object: 'crm_account', sharedWith: { type: 'team', value: 'sales' }, condition: 'true', accessLevel: 'read' };
case 'assignment_rule':
return { name, label: name, object: 'crm_account', entries: [{ name: 'e1', assignTo: { type: 'queue', value: 'triage' } }] };
case 'hook':
return { name, label: name, object: 'crm_account', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } };
case 'job':
return { name, label: name, schedule: { type: 'cron', expression: '0 * * * *' }, handler: 'noop' };
case 'page':
return { name, label: name, type: 'app', regions: [] };
case 'dashboard':
return { name, label: name, widgets: [] };
case 'report':
return { name, label: name, dataset: 'ds', values: ['total'] };
case 'book':
return { name, label: name, groups: [] };
case 'analytics_cube':
return { name, title: name, sql: 'select 1', measures: {}, dimensions: {} };
case 'tool':
return { name, label: name, description: 'A tool', parameters: {} };
default:
return { name, label: name };
}
};

const body = (id: string, ns: string | undefined, decl: Partial<Record<NamespaceUniqueType, string[]>> = {}, extra: Record<string, unknown> = {}) => {
const out: Record<string, unknown> = { id, name: id.split('.').pop(), version: '1.0.0', type: 'app', ...(ns ? { namespace: ns } : {}), ...extra };
for (const [type, names] of Object.entries(decl) as Array<[NamespaceUniqueType, string[]]>) {
out[COLLECTION[type]] = names.map((name) => item(type, name));
}
return out;
};

const artifact = (...bodies: Array<Record<string, unknown>>) => ({ packages: bodies.map((manifest) => ({ manifest })) });

describe.each(NAMESPACE_UNIQUE_TYPES)('%s — os validate refuses an artifact whose packages share a name inside one namespace', (type) => {
it('one row for the second package, naming both holders, in the package door\'s words', () => {
const rows = findNamespaceNameRefusals(artifact(
body('com.acme.alpha', 'crm', { [type]: ['shared_item'] }),
body('com.acme.beta', 'crm', { [type]: ['shared_item', 'beta_only'] }),
));
expect(rows).toHaveLength(1);
const [row] = rows;
expect([row.packageId, row.code, row.httpStatus]).toEqual(['com.acme.beta', NAMESPACE_CONFLICT_CODE, 422]);
expect(row.conflicts).toEqual([
{ namespace: 'crm', type, name: 'shared_item', incomingPackageId: 'com.acme.beta', existingPackageIds: ['com.acme.alpha'] },
]);
expect(row.message).toBe(namespaceNameConflictMessage(row.conflicts));
expect(row.message).toContain('"com.acme.alpha"');
expect(row.message).toContain('"com.acme.beta"');
});

it('CONTROL — the same name in two different namespaces is not refused', () => {
expect(findNamespaceNameRefusals(artifact(
body('com.acme.alpha', 'crm', { [type]: ['shared_item'] }),
body('com.other.hr', 'hr', { [type]: ['shared_item'] }),
))).toEqual([]);
});
});

describe('the walk', () => {
it('a stack with no `packages[]` is one package: never refused here', () => {
expect(findNamespaceNameRefusals({
manifest: { id: 'com.acme.alpha', name: 'alpha', version: '1.0.0', type: 'app', namespace: 'crm' },
pages: [item('page', 'home'), item('page', 'home')],
})).toEqual([]);
});

it('CONTROL — the same name across two types, and one package declaring a name twice, are not refused', () => {
expect(findNamespaceNameRefusals(artifact(
body('com.acme.alpha', 'crm', { page: ['home', 'home'] }),
body('com.acme.beta', 'crm', { dashboard: ['home'] }),
))).toEqual([]);
});

it('CONTROL — a shareable platform namespace is not judged', () => {
expect(findNamespaceNameRefusals(artifact(
body('com.platform.one', 'sys', { page: ['home'] }),
body('com.platform.two', 'sys', { page: ['home'] }),
))).toEqual([]);
});

it('every refused package is reported, and a refused one still counts as a holder for the packages after it', () => {
const rows = findNamespaceNameRefusals(artifact(
body('com.acme.alpha', 'crm', { report: ['pipeline'] }),
body('com.acme.beta', 'crm', { report: ['pipeline'] }),
body('com.acme.gamma', 'crm', { report: ['pipeline'] }),
));
expect(rows.map((r) => [r.packageId, r.conflicts[0].existingPackageIds])).toEqual([
['com.acme.beta', ['com.acme.alpha']],
['com.acme.gamma', ['com.acme.alpha', 'com.acme.beta']],
]);
});

it('walks in the boot\'s dependency order, not the array\'s: the dependent is the one refused', () => {
const rows = findNamespaceNameRefusals(artifact(
body('com.acme.billing', 'crm', { queue: ['triage'] }, { dependencies: { 'com.acme.core': '^1.0.0' } }),
body('com.acme.core', 'crm', { queue: ['triage'] }),
));
expect(rows.map((r) => [r.packageId, r.conflicts[0].existingPackageIds])).toEqual([
['com.acme.billing', ['com.acme.core']],
]);
});
});
88 changes: 88 additions & 0 deletions packages/cli/src/utils/namespace-co-owner-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0150 D2, child N] `os validate`'s and `os build`'s half of the package
* door's namespace name refusal: inside one namespace, an item of the twelve
* uncovered bucket 2/3 types (sharing rule, queue, assignment rule, hook, job,
* app, page, dashboard, report, book, analytics cube, tool) has one package
* holder.
*
* ## One judge, not a second rule
*
* The predicate and its sentence are `@objectstack/objectql`'s
* (`namespaceNameConflicts`, `namespaceNameConflictMessage`) — the SAME pair
* `SchemaRegistry.installPackage` throws the answer of. ⛔ Not an
* `@objectstack/lint` rule and not a re-spelling of the check: what this module
* owns is the WALK — which packages the boot installs, in which order.
*
* ## The walk is the boot path's
*
* The boot installs an artifact's `packages[]` bodies one after another, in
* `resolveArtifactPackageOrder`'s dependency order (`@objectstack/core`, the
* one reader of `packages[]`), and asks the predicate about each against the
* packages installed before it. This walk does the same: each body, in that
* order, against the bodies before it. A stack with no `packages[]` is one
* package, which never holds a name beside itself, so it is never refused
* here.
*
* Unlike the boot, which stops at the first refusal, every refused body is
* reported, and a refused body still counts as a holder for the bodies after
* it: the artifact declares it, and the author has to hear about every holder
* (red line 2: ambiguity is reported, never guessed).
*
* ⚠️ Bounds, stated rather than hidden: the packages a deployment already has
* installed are not visible here (that half is the package door's alone), and
* the residual — top-level items no body declares, registered under the
* stack's own id without an install — is not judged at either door.
*/

import { resolveArtifactPackageOrder } from '@objectstack/core';
import {
NAMESPACE_CONFLICT_CODE,
namespaceNameConflictMessage,
namespaceNameConflicts,
namespacePackageOf,
type NamespaceNameConflict,
type NamespacePackage,
} from '@objectstack/objectql';

/** One refused package, located. `message` is the package door's, verbatim. */
export interface NamespaceNameRefusalRow {
/** The refused package's id, as the registry installs it. */
packageId: string;
/** The ADR-0112 code the package door throws with. */
code: string;
/** The HTTP status the package door throws with. */
httpStatus: number;
/** The refusal, in the package door's own words. */
message: string;
/** Every `(type, name)` refused, each naming every holder. */
conflicts: NamespaceNameConflict[];
}

/**
* Every package of this stack's artifact that the package door would refuse
* for a name another package of its namespace holds, in the order the boot
* installs them. `[]` for a stack the door accepts on this axis.
*/
export function findNamespaceNameRefusals(parsed: Record<string, unknown>): NamespaceNameRefusalRow[] {
if (parsed.packages === undefined) return [];
const installed: NamespacePackage[] = [];
const rows: NamespaceNameRefusalRow[] = [];
for (const body of resolveArtifactPackageOrder(parsed)) {
const incoming = namespacePackageOf(body);
if (incoming === undefined) continue;
const conflicts = namespaceNameConflicts(incoming, installed);
if (conflicts.length > 0) {
rows.push({
packageId: incoming.packageId,
code: NAMESPACE_CONFLICT_CODE,
httpStatus: 422,
message: namespaceNameConflictMessage(conflicts),
conflicts,
});
}
installed.push(incoming);
}
return rows;
}
Loading
Loading