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
17 changes: 17 additions & 0 deletions .changeset/33069-define-stack-packages-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
---

feat(spec)!: strict `defineStack` judges a `packages[]` stack's namespace prefixes per package body, and its top level by the residual rule, the way the load path registers its objects (ADR-0028, ADR-0130 D4)

Clause-②: yes (narrowing) — one face widens and one narrows. Widened: a stack carrying `packages[]` whose top level repeats objects that package bodies of different namespaces declare (an additive artifact, such as `resolveArtifactCollections` output) is accepted, where it was refused. Narrowed: a `packages[]` body whose object its own body's namespace does not prefix is refused, where it was accepted when the stack's top level held no copy of it or a copy the manifest's namespace prefixes.
Metadata-protocol: narrowed

<!-- adr-0087: registered define-stack-packages-body-namespace-prefix-refused -->

**BREAKING** accept-set narrowing in strict `defineStack` (`@objectstack/spec`, the root export), for a stack that carries `packages[]`.

- **Before.** Strict `defineStack` judged every top-level object of such a stack against the stack manifest's one `namespace`, and no package body's objects at all. The load path registers each body's objects under that body's own package and namespace, and only the top-level objects no body declares under the manifest id (the residual rule, `unclaimedTopLevel` in `@objectstack/metadata`). So the reader round trip of a suite whose packages declare different namespaces, `defineStack(resolveArtifactCollections(composeStacks(stacks, { manifest: 'preserve' })))`, was refused with `STACK_NAMESPACE_PREFIX_INVALID`, and a body object its own namespace does not prefix passed.
- **Now.** Each body's objects are judged against that body's own `namespace`, the finding located as `Package 'ID' (packages[J].manifest.objects[I]): ` before the rule's sentence. A top-level object that no body declares is judged against `manifest.namespace` (`Top-level objects[I], which no package body declares, is registered under package 'ID': `). A top-level object a body declares is that body's object and is judged with the body only, so the two-namespace round trip is accepted.
- **Unchanged.** A stack without `packages[]` (every authored package): every object is judged against its own `manifest.namespace`, with the same sentence. A package with no `namespace` is not judged, and `defineStack(…, { strict: false })` judges nothing. Every body that came out of its own strict `defineStack` call was already judged this way.
- **Fix.** Rename the object to its own package's prefix (`NAMESPACE_shortName`; the finding names the body and the exact rename), or move it into the package body whose namespace it carries.
28 changes: 28 additions & 0 deletions packages/runtime/src/artifact-collections.public-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,31 @@ describe('resolveArtifactCollections — its output handed back to strict define
expect(() => defineStack(read as never)).not.toThrow();
});
});

describe('resolveArtifactCollections — its output handed back to strict defineStack passes the namespace-prefix rule when the packages declare different namespaces (ADR-0028, ADR-0130 D4)', () => {
// The reader merges every body's objects onto the top level beside
// `packages[]`. Each of those objects is declared by a body, so the load
// path registers it under that body's own namespace, and strict
// `defineStack` judges it there too, not against the artifact's manifest.
const strictPackage = (id: string, type: 'app' | 'module', namespace: string, slug: string) =>
defineStack({
manifest: { id, name: slug, version: '1.0.0', type, namespace },
objects: [{ name: `${namespace}_${slug}`, label: slug, fields: { title: { type: 'text', label: 'Title' } } }],
} as never);

it('accepts the reader\'s output of a preserve composition of two namespaces', () => {
const preserved = composeStacks(
[strictPackage('com.example.crm', 'app', 'crm', 'account'), strictPackage('com.example.svc', 'module', 'svc', 'case')],
{ manifest: 'preserve' },
);
const read = resolveArtifactCollections(preserved) as Raw & { manifest?: { namespace?: string } };

// Anti-vacuity: both bodies' objects at the top level, one of them not
// prefixed by the manifest's namespace.
expect(((read.objects as Array<{ name: string }>) ?? []).map((o) => o.name)).toEqual(['crm_account', 'svc_case']);
expect(read.manifest?.namespace).toBe('svc');
expect(Array.isArray(read.packages)).toBe(true);

expect(() => defineStack(read as never)).not.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// An accept-set narrowing of strict defineStack on a stack that carries
// packages[]: its namespace prefixes are judged the way the load path
// registers its objects, each package body's objects against that body's own
// namespace (ADR-0028 prefix rule, ADR-0130 D4). No D2 conversion: which
// package an object belongs to, and so its name, is the author's call.
export const entry: SemanticMigration = {
id: 'define-stack-packages-body-namespace-prefix-refused',
surface:
'defineStack(stack) (strict, the default) on a stack that carries packages[] whose package body declares '
+ 'a namespace and an object that namespace does not prefix, e.g. a hand-assembled packages[] body, or one '
+ 'built with defineStack(…, { strict: false }), whose object carries another package\'s prefix',
replacement:
'Rename the object to its own package\'s prefix (NAMESPACE_shortName; the finding names the package body '
+ 'and the exact rename), or move it into the package body whose namespace it carries. A top-level object a '
+ 'package body declares (an additive artifact\'s merged copy, such as the output of '
+ 'resolveArtifactCollections from @objectstack/runtime) is judged with that body only and is now accepted '
+ 'when the packages declare different namespaces. A stack without packages[] is judged as before.',
reason:
'The load path registers each packages[] body\'s objects under that body\'s own package and namespace, and '
+ 'a top-level object that no body declares under the stack\'s manifest id (the residual rule, '
+ 'unclaimedTopLevel in @objectstack/metadata). Strict defineStack judged every top-level object against the '
+ 'manifest\'s one namespace instead and never judged a body\'s objects, so it refused the reader round trip '
+ 'of a suite whose packages declare different namespaces, and it accepted a body object its own namespace '
+ 'does not prefix. defineStack now judges each body against its own namespace and the residual against the '
+ 'manifest\'s, and refuses the second shape with STACK_NAMESPACE_PREFIX_INVALID (422). Not convertible: an '
+ 'object\'s name is its table name and its API name, which only the author can rename.',
acceptanceCriteria:
'os validate and os build, and every config or test helper that calls defineStack on a packages[] stack, run '
+ 'without a STACK_NAMESPACE_PREFIX_INVALID finding. Then boot the artifact and confirm each object answers on '
+ '/api/v1/data under its own package.',
};
77 changes: 77 additions & 0 deletions packages/spec/src/stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1813,6 +1813,83 @@ describe('defineStack — a packages[] stack is judged per body, and its top lev
});
});

describe('defineStack — a packages[] stack\'s namespace prefixes are judged per body, and its top level by the residual rule (ADR-0028, ADR-0130 D4)', () => {
// The load path registers each body under its own id, its objects under that
// body's own namespace, and a top-level object that no body declares under
// the stack's manifest id (`unclaimedTopLevel`, `@objectstack/metadata`).
// Strict `defineStack` judges a `packages[]` stack's prefixes the same way, so
// the two packages here declare DIFFERENT namespaces.
const objectOf = (name: string) => ({ name, label: name, fields: { title: { type: 'text' as const } } });
const pkg = (id: string, type: 'app' | 'module', namespace: string) =>
({ id, version: '1.0.0', type, name: id, namespace });
const sales = () => defineStack({ manifest: pkg('com.example.crm', 'app', 'crm'), objects: [objectOf('crm_account')] });
const service = () => defineStack({ manifest: pkg('com.example.svc', 'module', 'svc'), objects: [objectOf('svc_case')] });
/** A suite artifact: two bodies of two namespaces, under the service package's manifest. */
const suite = () => composeStacks([sales(), service()], { manifest: 'preserve' });
/** The shape `resolveArtifactCollections` (`@objectstack/runtime`) returns: every body's objects merged onto the top level. */
const additive = () => ({ ...suite(), objects: [objectOf('crm_account'), objectOf('svc_case')] });

type Refusal = { code?: string; status?: number; issues?: readonly string[]; message: string };
const refusalOf = (stack: unknown): Refusal | null => {
try {
defineStack(stack as never);
return null;
} catch (e) {
return e as Refusal;
}
};

it('accepts an additive artifact whose top level repeats the objects of two bodies of different namespaces: each is judged with its own body', () => {
const artifact = additive();
// Anti-vacuity: a top-level object the manifest's namespace does not prefix.
expect(artifact.manifest?.namespace).toBe('svc');
expect(artifact.objects.map((o) => o.name)).toContain('crm_account');
expect(refusalOf(artifact)).toBeNull();
});

it('refuses a top-level object that no body declares when the manifest\'s namespace does not prefix it: the load path registers it under the manifest id', () => {
const artifact = additive();
const r = refusalOf({ ...artifact, objects: [...artifact.objects, objectOf('zzz_orphan')] });
expect(r, 'refused').not.toBeNull();
expect(r!.code).toBe('STACK_NAMESPACE_PREFIX_INVALID');
expect(r!.status).toBe(422);
expect(r!.issues).toEqual([
"Top-level objects[2], which no package body declares, is registered under package 'com.example.svc': " +
"Object 'zzz_orphan' is missing the package namespace prefix. Rename it to 'svc_zzz_orphan' (namespace = 'svc').",
]);
// …and one the manifest's namespace prefixes is accepted.
expect(refusalOf({ ...artifact, objects: [...artifact.objects, objectOf('svc_orphan')] })).toBeNull();
});

it('refuses a body object its own body\'s namespace does not prefix, located at the body, though another body\'s namespace or the manifest\'s would', () => {
const artifact = suite();
const packages = artifact.packages!.map((entry, j) =>
(j === 0 ? { ...entry, manifest: { ...entry.manifest, objects: [...((entry.manifest.objects ?? []) as unknown[]), objectOf('svc_stray')] } } : entry));
const r = refusalOf({ ...artifact, packages });
expect(r?.code).toBe('STACK_NAMESPACE_PREFIX_INVALID');
expect(r?.status).toBe(422);
expect(r?.issues).toEqual([
"Package 'com.example.crm' (packages[0].manifest.objects[1]): " +
"Object 'svc_stray' is missing the package namespace prefix. Rename it to 'crm_svc_stray' (namespace = 'crm').",
]);
});

it('control: a stack without packages[] judges every object against its own namespace, with the rule\'s own sentence unchanged', () => {
const r = refusalOf({ manifest: pkg('com.example.svc', 'module', 'svc'), objects: [objectOf('svc_case'), objectOf('crm_account')] });
expect(r?.code).toBe('STACK_NAMESPACE_PREFIX_INVALID');
expect(r?.status).toBe(422);
expect(r?.issues).toEqual([
"Object 'crm_account' is missing the package namespace prefix. Rename it to 'svc_crm_account' (namespace = 'svc').",
]);
expect(r?.message).toBe(
'defineStack namespace-prefix validation failed (1 issue):\n\n' +
" ✗ Object 'crm_account' is missing the package namespace prefix. Rename it to 'svc_crm_account' (namespace = 'svc')." +
'\n\nEvery object.name must be `${manifest.namespace}_${shortName}`. This is the only supported writing style — ' +
'the platform does not provide ns() helpers or factory wrappers.',
);
});
});

// ── ADR-0087 D2 conversion notices reach the author ─────────────────────────
//
// Moved to `stack-conversion-notices.test.ts` with #23543 F5-http: the block
Expand Down
77 changes: 67 additions & 10 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2541,6 +2541,12 @@ export interface DefineStackOptions {
* Rules:
* - When `manifest.namespace` is set, every `object.name` MUST start with
* `${namespace}_` (single underscore). Returns one error per offender.
* - What is judged against which namespace is what the load path registers
* under each package ({@link namespacePrefixErrors}): a stack without
* `packages[]` is its one package, so every top-level object is judged
* against `manifest.namespace`; a stack with `packages[]` (ADR-0130 D4) is
* judged per body, each body's objects against that body's own namespace,
* and its top level by the residual rule.
* - Names starting with `sys_` are platform-reserved and always allowed.
* - Names containing `__` (legacy FQN double-underscore form) are flagged
* so authors migrate to the canonical single-prefix form.
Expand All @@ -2556,16 +2562,7 @@ export interface DefineStackOptions {
* prefixed references will surface there once the objects are correct.
*/
function validateNamespacePrefix(config: ObjectStackDefinition): string[] {
const errors: string[] = [];
const ns = config.manifest?.namespace;
if (ns && config.objects) {
// Single source of the per-object prefix rule — shared verbatim with the
// runtime publish enforcement in MetadataManager.publishPackage.
for (const obj of config.objects) {
const err = validateObjectNamespacePrefix(obj.name, ns);
if (err) errors.push(err);
}
}
const errors = namespacePrefixErrors(config as unknown as Record<string, unknown>);

// [ADR-0150 D6, child K] …and a namespace never equals an object name, in
// either direction, and no object is short-named `custom` (`NS_custom`, the
Expand Down Expand Up @@ -2599,6 +2596,66 @@ function validateNamespacePrefix(config: ObjectStackDefinition): string[] {
return errors;
}

/**
* The namespace-prefix rule ({@link validateObjectNamespacePrefix}, the single
* source shared verbatim with the runtime publish enforcement in
* `MetadataManager.publishPackage`) applied to each object under the namespace
* of the package the load path registers it under:
*
* - **A stack with no `packages[]`** is its one package, so every top-level
* object is judged against `manifest.namespace`, and the finding is the
* rule's own sentence.
* - **A stack with `packages[]`** (ADR-0130 D4) registers each body under its
* own id, so each body's objects are judged against that body's own
* `namespace`. A top-level object that no body declares is the stack's
* residual, registered under `manifest.id` by the boot's residual rule
* (`unclaimedTopLevel`, `@objectstack/metadata`, mirrored by
* {@link isResidualTopLevelItem}), and is judged against
* `manifest.namespace`. A top-level object a body declares is a copy of that
* body's object (an additive artifact's merged collections, as
* `resolveArtifactCollections` gives them) and is judged with that body
* only, so a suite whose packages declare different namespaces is not
* judged against one of them.
*
* A package with no `namespace` is a legacy package: its objects are not
* judged, the rule's own skip.
*/
function namespacePrefixErrors(config: Record<string, unknown>): string[] {
const manifest = isRecord(config.manifest) ? config.manifest : undefined;
const namespaceOf = (body: Record<string, unknown> | undefined): string | undefined =>
typeof body?.namespace === 'string' ? body.namespace : undefined;
const judge = (
objects: unknown,
namespace: string | undefined,
where: (i: number) => string,
judged?: (item: unknown) => boolean,
): string[] => {
if (!namespace || !Array.isArray(objects)) return [];
return objects.flatMap((obj: unknown, i: number) => {
if (judged && !judged(obj)) return [];
const name = isRecord(obj) && typeof obj.name === 'string' ? obj.name : undefined;
const err = validateObjectNamespacePrefix(name, namespace);
return err ? [`${where(i)}${err}`] : [];
});
};
if (!Array.isArray(config.packages)) return judge(config.objects, namespaceOf(manifest), () => '');
const bodies: Record<string, unknown>[] = [];
const errors: string[] = [];
config.packages.forEach((entry: unknown, j: number) => {
const body = isRecord(entry) && isRecord(entry.manifest) ? entry.manifest : undefined;
if (body === undefined) return;
bodies.push(body);
errors.push(...judge(body.objects, namespaceOf(body),
(i) => `Package '${singleAppPackageLabel(body)}' (packages[${j}].manifest.objects[${i}]): `));
});
const isResidual = isResidualTopLevelItem(bodies);
errors.push(...judge(config.objects, namespaceOf(manifest),
(i) => `Top-level objects[${i}], which no package body declares, is registered under package ` +
`'${singleAppPackageLabel(manifest)}': `,
(obj) => isResidual('objects', obj)));
return errors;
}

/**
* The package types a suite is made of, each held to at most one app —
* ADR-0019 (app-as-consumer-unit) D3 as amended: a suite (one install of an
Expand Down
Loading