@@ -317,6 +317,17 @@ function isRealPackage(pkg: unknown): pkg is string {
317317 return typeof pkg === 'string' && pkg . length > 0 && pkg !== SYS_METADATA_OWNER ;
318318}
319319
320+ /**
321+ * Platform namespaces that multiple packages may legitimately share, so the
322+ * install-time namespace-uniqueness gate (ADR-0048 Phase 1) must never fire on
323+ * them: the FQN-exempt reserved namespaces (`base`, `system`) plus `sys`
324+ * (system objects such as `sys_metadata` are contributed by many packages — see
325+ * {@link SchemaRegistry.registerNamespace}, which is intentionally many-to-one).
326+ */
327+ function isShareableNamespace ( ns : string ) : boolean {
328+ return RESERVED_NAMESPACES . has ( ns ) || ns === 'sys' ;
329+ }
330+
320331/**
321332 * Raised when two **different** code packages register a generic (non-object)
322333 * metadata item under the same `(type, name)` in the code-defined base layer
@@ -357,6 +368,43 @@ export class MetadataCollisionError extends Error {
357368 }
358369}
359370
371+ /**
372+ * Raised when a package is installed whose `manifest.namespace` is already owned
373+ * by a **different** installed package in this installation (ADR-0048 Phase 1).
374+ *
375+ * The namespace is the mandatory object-name prefix (`${namespace}_${shortName}`)
376+ * and — once installed — the container that scopes a package's UI/automation
377+ * metadata. Two packages sharing a namespace would collide at the object/table
378+ * layer (a duplicate `CREATE TABLE crm_account` already fails loudly at the DB)
379+ * and would make container-scoped resolution ambiguous. This gate refuses the
380+ * install up front with an actionable error, instead of letting a half-applied
381+ * install blow up later at table creation. Shareable platform namespaces
382+ * (`base`/`system`/`sys`) are exempt.
383+ */
384+ export class NamespaceConflictError extends Error {
385+ readonly namespace : string ;
386+ readonly existingPackageId : string ;
387+ readonly incomingPackageId : string ;
388+
389+ constructor ( namespace : string , existingPackageId : string , incomingPackageId : string ) {
390+ super (
391+ `Namespace conflict: namespace "${ namespace } " is already owned by ` +
392+ `package "${ existingPackageId } ", so package "${ incomingPackageId } " ` +
393+ `cannot be installed alongside it. A namespace is the mandatory prefix ` +
394+ `of every object name (e.g. "${ namespace } _account") and the container ` +
395+ `that scopes a package's UI metadata, so it must be unique per ` +
396+ `installation. Choose a different namespace for "${ incomingPackageId } ", ` +
397+ `or uninstall "${ existingPackageId } " first. If this is a deliberate ` +
398+ `migration, set OS_METADATA_COLLISION=warn to downgrade to a warning. ` +
399+ `See ADR-0048.` ,
400+ ) ;
401+ this . name = 'NamespaceConflictError' ;
402+ this . namespace = namespace ;
403+ this . existingPackageId = existingPackageId ;
404+ this . incomingPackageId = incomingPackageId ;
405+ }
406+ }
407+
360408export class SchemaRegistry {
361409 // ==========================================
362410 // Logging control
@@ -837,7 +885,15 @@ export class SchemaRegistry {
837885 // artifact-vs-DB warning below.
838886 if ( isRealPackage ( packageId ) ) {
839887 const conflictOwner = this . findOtherPackageOwner ( collection , baseName , packageId ) ;
840- if ( conflictOwner ) {
888+ if ( conflictOwner && ! this . isPreferLocalDisambiguable ( packageId , conflictOwner ) ) {
889+ // ADR-0048 Phase 2 — the guard now only fires when prefer-local
890+ // resolution (see getItem) CANNOT disambiguate the two owners: they
891+ // share a namespace (possible only for shareable platform namespaces
892+ // like `sys`, which the install gate exempts) or one lacks a namespace
893+ // (legacy package with no container to scope by). Two packages in
894+ // DIFFERENT namespaces legitimately coexist on the same bare name —
895+ // the install-time namespace gate keeps their namespaces distinct and
896+ // prefer-local routes each caller to its own container.
841897 const err = new MetadataCollisionError ( type , baseName , conflictOwner , packageId ) ;
842898 if ( this . collisionPolicy === 'warn' ) {
843899 console . warn ( `[Registry] ${ err . message } ` ) ;
@@ -894,6 +950,21 @@ export class SchemaRegistry {
894950 return undefined ;
895951 }
896952
953+ /**
954+ * True when prefer-local resolution ({@link getItem}) can route callers in
955+ * each owner's container to the right item — i.e. the two packages declare
956+ * **distinct, non-empty namespaces** (ADR-0048 Phase 2). When it returns
957+ * false (shared namespace, or a missing namespace on either side) a same
958+ * `(type, name)` clash is genuinely unresolvable and the collision guard
959+ * fires. Namespaces are read from the installed-package records, which the
960+ * install path registers before a package's metadata is loaded.
961+ */
962+ private isPreferLocalDisambiguable ( incoming : string , owner : string ) : boolean {
963+ const incomingNs = this . getPackage ( incoming ) ?. manifest ?. namespace ;
964+ const ownerNs = this . getPackage ( owner ) ?. manifest ?. namespace ;
965+ return ! ! incomingNs && ! ! ownerNs && incomingNs !== ownerNs ;
966+ }
967+
897968 /**
898969 * Validate Metadata against Spec Zod Schemas
899970 */
@@ -939,19 +1010,40 @@ export class SchemaRegistry {
9391010 }
9401011
9411012 /**
942- * Universal Get Method
1013+ * Universal Get Method.
1014+ *
1015+ * ADR-0048 Phase 2 — *prefer-local* resolution. When `currentNamespace` is
1016+ * given (the container the caller is resolving within), a bare name resolves
1017+ * to the item owned by *that namespace's* package before any cross-package
1018+ * fallback, so two packages shipping e.g. `page/home` no longer resolve by
1019+ * registration order (first-match-wins). Omitting `currentNamespace`
1020+ * preserves the legacy resolution exactly, so this is backward compatible.
1021+ *
1022+ * Precedence (highest first):
1023+ * 1. bare-key runtime/DB overlay (ADR-0005 sanctioned override) — unchanged
1024+ * 2. the `currentNamespace` owner's composite entry (prefer-local)
1025+ * 3. first composite match (legacy first-registered-wins fallback)
9431026 */
944- getItem < T > ( type : string , name : string ) : T | undefined {
1027+ getItem < T > ( type : string , name : string , currentNamespace ?: string ) : T | undefined {
9451028 // Special handling for 'object' and 'objects' types - use objectContributors
9461029 if ( type === 'object' || type === 'objects' ) {
9471030 return this . getObject ( name ) as unknown as T | undefined ;
9481031 }
949-
1032+
9501033 const collection = this . metadata . get ( type ) ;
9511034 if ( ! collection ) return undefined ;
9521035 const direct = collection . get ( name ) ;
9531036 if ( direct ) return direct as T ;
954- // Scan for composite keys
1037+
1038+ // Prefer-local: resolve within the caller's container (namespace) first.
1039+ if ( currentNamespace ) {
1040+ for ( const owner of this . getNamespaceOwners ( currentNamespace ) ) {
1041+ const local = collection . get ( `${ owner } :${ name } ` ) ;
1042+ if ( local ) return local as T ;
1043+ }
1044+ }
1045+
1046+ // Fallback: first composite key matching the bare name (legacy behaviour).
9551047 for ( const [ key , item ] of collection ) {
9561048 if ( key . endsWith ( `:${ name } ` ) ) {
9571049 return item as T ;
@@ -1080,6 +1172,30 @@ export class SchemaRegistry {
10801172 // ==========================================
10811173
10821174 installPackage ( manifest : ObjectStackManifest , settings ?: Record < string , any > ) : InstalledPackage {
1175+ // ADR-0048 Phase 1 — install-time namespace gate. Refuse a package whose
1176+ // namespace is already owned by a *different* installed package; this is
1177+ // the constraint the object/table layer enforces implicitly (a duplicate
1178+ // `CREATE TABLE <ns>_<obj>` fails at the DB), made explicit and early.
1179+ // Same-package reinstall/reload is excluded (owner === manifest.id), and
1180+ // shareable platform namespaces (base/system/sys) are exempt.
1181+ if ( manifest . namespace && ! isShareableNamespace ( manifest . namespace ) ) {
1182+ const conflictOwner = this . getNamespaceOwners ( manifest . namespace ) . find (
1183+ ( owner ) => owner !== manifest . id ,
1184+ ) ;
1185+ if ( conflictOwner ) {
1186+ if ( this . collisionPolicy === 'warn' ) {
1187+ console . warn (
1188+ `[Registry] Namespace conflict (downgraded to warning via ` +
1189+ `OS_METADATA_COLLISION=warn): namespace "${ manifest . namespace } " is ` +
1190+ `already owned by "${ conflictOwner } "; installing "${ manifest . id } " ` +
1191+ `anyway. See ADR-0048.` ,
1192+ ) ;
1193+ } else {
1194+ throw new NamespaceConflictError ( manifest . namespace , conflictOwner , manifest . id ) ;
1195+ }
1196+ }
1197+ }
1198+
10831199 const now = new Date ( ) . toISOString ( ) ;
10841200 const disabled = this . initialDisabledPackageIds . has ( manifest . id ) ;
10851201 const pkg : InstalledPackage = {
@@ -1175,7 +1291,10 @@ export class SchemaRegistry {
11751291 }
11761292
11771293 getApp ( name : string ) : any {
1178- const app = this . getItem ( 'app' , name ) ;
1294+ // ADR-0048 (v1) — one app per package, with `app.name ≡ manifest.namespace`,
1295+ // so the app name *is* its own container: resolve prefer-local against it so
1296+ // two packages' apps never resolve by registration order.
1297+ const app = this . getItem ( 'app' , name , name ) ;
11791298 if ( ! app ) return app ;
11801299 return this . applyNavContributions ( app ) ;
11811300 }
0 commit comments