Skip to content

Commit c78ebd9

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(objectql): ADR-0048 Phase 1+2 — namespace install gate + prefer-local resolution (#1810)
* chore: bump objectui to 893e5302174c feat(ADR-0046): package documentation portal + nav entry (#1686) objectui@893e5302174c7cbf75a7bed9e8e6dcb935273363 * feat(objectql): ADR-0048 Phase 1+2 — namespace install gate + prefer-local resolution Phase 1 — install-time namespace gate. `SchemaRegistry.installPackage` refuses a package whose `manifest.namespace` is already owned by a DIFFERENT installed package (new `NamespaceConflictError`), making explicit and early the constraint the object/table layer already enforces implicitly (a duplicate `CREATE TABLE <ns>_<obj>` fails at the DB). Same-package reinstall and shareable platform namespaces (base/system/sys) are exempt; OS_METADATA_COLLISION=warn downgrades to a warning. Phase 2 — prefer-local (container-scoped) resolution. `getItem(type, name, ns?)` resolves a bare name to the item owned by `ns`'s package before any cross-package fallback, preserving ADR-0005 overlay precedence and remaining backward compatible (param optional). `getApp` resolves prefer-local against its own name (app.name ≡ namespace in v1). The per-item collision guard now narrows to the cases prefer-local CANNOT disambiguate (shared/missing namespace), so two DIFFERENT-namespace packages legitimately coexist on the same bare name — the marketplace coexistence the revised ADR-0048 targets. Every existing collision test still passes (namespace-less fixtures remain a hard error). Tests: registry-namespace-install-gate.test.ts (7), registry-prefer-local- resolution.test.ts (7). Full objectql suite green (600). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(objectql): pivot ADR-0048 to option A — package-id-scoped resolution Per the sharpened ADR-0048 decision: the URL/route key is the package id (A), not the namespace. Migration cost is symmetric between A and B (both change one thing), so A wins on the dimensions that matter for an open marketplace — coordination-free global uniqueness, a single identity coordinate, no land-grab, and a self-describing URL. Backend: - getItem(type, name, currentPackageId?) prefers `${currentPackageId}:${name}` directly (was namespace→owner indirection). ADR-0005 overlay precedence and backward compatibility unchanged. - getApp(name, currentPackageId?) resolves prefer-local by package id. - Retire the per-item CROSS-package throw: package ids are globally unique, so package-scoped resolution always disambiguates two distinct packages — what the old guard flagged is now the supported coexistence case. Remove the now- dead MetadataCollisionError, findOtherPackageOwner, isPreferLocalDisambiguable, isRealPackage, SYS_METADATA_OWNER. collisionPolicy now governs only the Phase 1 namespace gate. Tests: rewrite *-cross-package-collision.test.ts from "throws" to "coexists + package-scoped resolves"; prefer-local test keys on package id. Full objectql suite green (598). ADR: §3.3 package-scoped (package-id) resolution; §3.4 cross-package throw retired; §3.6 transparent package-id URL + optional per-tenant namespace alias; phasing marks Phase 1 + Phase 2 backend/frontend done, package-id routing as the remaining frontend step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: re-trigger checks for the option-A pivot commit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 060467a commit c78ebd9

5 files changed

Lines changed: 227 additions & 344 deletions

docs/adr/0048-cross-package-metadata-collision.md

Lines changed: 101 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# ADR-0048: Cross-package metadata collision — package-scoped identity, namespace install gate, container-scoped resolution
1+
# ADR-0048: Cross-package metadata collision — package-id identity, namespace install gate, package-scoped resolution
22

33
**Status**: Revised (2026-06-13) — supersedes the original *per-item collision
44
detection* framing. The runtime guard shipped under the original proposal is
@@ -42,15 +42,22 @@ resolution**, not write-time clash detection:
4242
*different* installed package is **refused** — making explicit and early a
4343
constraint the object/table layer already enforces implicitly (a duplicate
4444
`CREATE TABLE crm_account` fails loudly at the DB).
45-
3. **Resolution is container-scoped.** UI/automation metadata resolves within
46-
its package/app container, so a cross-package clash on a bare name is
47-
*structurally impossible* rather than *detected after the fact*.
48-
4. **Per-item runtime detection (shipped) is demoted to a same-package
49-
authoring backstop** + an authoring-time lint — it now only catches an
50-
author shipping two `page/home` *within one package*.
45+
3. **Resolution is package-scoped, keyed on the package id.** A bare name
46+
resolves within the caller's package first (`getItem(type, name,
47+
currentPackageId?)`; items already carry `_packageId`). Because package ids
48+
are globally unique, two packages shipping `page/home` coexist and each
49+
caller resolves to its own — a cross-package clash *cannot mis-resolve* for
50+
any caller carrying its package id.
51+
4. **The per-item cross-package throw retires.** Distinct packages are always
52+
disambiguable by package id, so what the original guard flagged as a
53+
collision is now the *supported* coexistence case. Same-package writes
54+
overwrite (idempotent reload); the `os lint` namespace-prefix rule keeps
55+
authoring hygiene.
5156
5. **Namespace rename-on-install is an explicit non-goal for now** (deep
5257
rewrite of every object name, cross-reference, and formula). v1 is
5358
*refuse-on-conflict*; rename is a separate future work item.
59+
6. **The package-id URL is transparent; a per-tenant namespace alias is an
60+
optional sugar** (`/apps/crm``com.acme.crm`), never the stored identity.
5461

5562
## 1. Context
5663

@@ -203,35 +210,52 @@ DB). The gate just makes that constraint **explicit and early** (a clean
203210
pre-install check) instead of a half-applied install blowing up at
204211
`CREATE TABLE`.
205212

206-
Reserved namespaces (`base`, `system`, `sys`) are exempt, as today.
213+
Note the gate is **not load-bearing for routing** under §3.1 — routing keys on
214+
the globally-unique package id, which is correct with or without the gate
215+
(local dev, build-time, federation). The gate serves the object/table layer
216+
and is the basis for the optional per-tenant alias (§3.6). Reserved namespaces
217+
(`base`, `system`, `sys`) are exempt, as today.
207218

208-
### 3.3 Resolution is container-scoped (prefer-local, qualify-to-cross)
219+
### 3.3 Resolution is package-scoped (prefer-local, qualify-to-cross)
209220

210221
`getItem`/route resolution resolves a bare name **within the current package
211-
container first**:
222+
first**, keyed on the **package id**:
212223

213224
- Within `/apps/<packageId>/…`, a bare `page/home` resolves to *this
214-
package's* `home`. The current package is already known from the route and
215-
from React context (`activeApp`), so this is a single-field scoping, not a
216-
signature change at every call site.
225+
package's* `home`. The current package id is already known from the route and
226+
from React context (`activeApp._packageId`), so this is a single-field
227+
scoping — `getItem(type, name, currentPackageId?)` — not a signature change
228+
at every call site. Items already carry their owner (`_packageId`, ADR-0010),
229+
so the match is `_packageId === currentPackageId`.
217230
- A **deliberate cross-package reference** uses a qualified form
218-
(`<packageId>:<name>` / `<namespace>:<name>`) — the only place a second
219-
package's metadata is reachable, and it is explicit.
220-
221-
Because two packages can never share a namespace (§3.2) and bare names resolve
222-
inside the container (§3.3), **a cross-package clash on `page/home` cannot
223-
occur** — there is nothing left to detect at the cross-package level.
224-
225-
### 3.4 Per-item runtime detection is demoted to a same-package backstop
226-
227-
The shipped guard (`MetadataCollisionError`, `collisionPolicy`,
228-
`OS_METADATA_COLLISION`, `findOtherPackageOwner` in
229-
`packages/objectql/src/registry.ts`) **stays**, but its role narrows: with the
230-
namespace gate (§3.2) eliminating cross-package clashes, the only remaining
231-
duplicate is **one package shipping two items with the same `(type, name)`**
232-
an authoring mistake, best caught by the `naming/namespace-prefix` lint in
233-
`os lint` at authoring time, with the runtime guard as the last-line backstop.
234-
The guard is cheap and already there; no reason to remove it.
231+
(`<packageId>:<name>`) — the only place a second package's metadata is
232+
reachable, and it is explicit.
233+
234+
The disambiguation rests on the **package id being globally unique**, *not* on
235+
the namespace gate: two packages legitimately ship `page/home`, store under
236+
distinct composite keys (`com.acme.crm:home`, `com.acme.hr:home`), and each
237+
caller resolves to its own package's item. **A cross-package clash on
238+
`page/home` therefore cannot mis-resolve for any caller that carries its
239+
package id** — which every routed UI surface does. A *context-free* read
240+
(`getItem` with no package id) is best-effort: it returns the first match and
241+
the caller is expected to pass the package id when it cares.
242+
243+
### 3.4 Per-item cross-package detection retires; same-package overwrite stays
244+
245+
The original proposal's per-item guard threw `MetadataCollisionError` whenever
246+
two **different** packages registered the same `(type, name)`. Under
247+
package-scoped resolution that is exactly the case we now *support*: package
248+
ids are always distinct, so prefer-local always disambiguates two different
249+
packages — there is no unresolvable cross-package clash to detect. The
250+
cross-package **throw is retired**; two distinct packages coexist on the same
251+
bare name by construction.
252+
253+
What remains is the narrow, genuinely-ambiguous case the guard still earns its
254+
keep on: **a write with no real package provenance** (a `sys_metadata`/runtime
255+
overlay) is governed by the ADR-0005 overlay precedence (artifact-vs-DB warning,
256+
unchanged), and **same-package re-registration** simply overwrites (idempotent
257+
reload). Authoring-time hygiene — an author shipping two `page/home` in one
258+
package — stays covered by the `naming/namespace-prefix` lint in `os lint`.
235259

236260
### 3.5 Namespace rename-on-install is deferred (non-goal)
237261

@@ -241,16 +265,34 @@ formula / view / flow that names `crm_account`. That is a deep rewrite, not a
241265
URL change. v1 is **refuse-on-conflict** (§3.2). Rename-on-install is recorded
242266
as future work, not part of this decision.
243267

268+
### 3.6 The package-id URL is transparent; a friendly alias is optional
269+
270+
A reverse-domain URL — `/apps/com.acme.crm/page/home` — is **self-describing**:
271+
vendor (`acme`), product (`crm`), and surface are legible at a glance, the way
272+
Android package names, Java FQNs, and `k8s` `namespace/name` are. For a host
273+
that runs third-party packages this is a feature, not noise: "which package is
274+
this page from?" is answerable from the URL alone — a trust, support, and
275+
debugging win — and one vendor's `crm` cannot be mistaken for another's.
276+
277+
Its one real cost is **length**. When a short URL is wanted, a host MAY expose a
278+
**per-tenant friendly alias**`/apps/crm` resolving to `com.acme.crm`
279+
because the namespace gate (§3.2) makes the namespace unique *within a tenant*.
280+
The alias is a tenant-local presentation convenience layered over the canonical
281+
package-id route; it is **never** the stored identity. Canonical = package id
282+
(robust, coordination-free); alias = namespace (pretty, tenant-scoped). The
283+
alias is optional and out of scope for the phases below.
284+
244285
## 4. Consequences
245286

246287
- **Two vendors' packages coexist.** `com.acme.crm` and `com.beta.crm` install
247288
side by side; each `home` page is reachable under its own
248289
`/apps/<packageId>/…` container. The marketplace becomes viable for
249290
common-named packages.
250-
- **One per-package gate replaces N per-item checks.** Cross-package safety is
251-
an `O(1)`-per-package namespace check at install, not an `O(every
252-
page/dashboard/flow)` registration scan — cheaper, right granularity, and it
253-
fires *before* a half-install.
291+
- **Cross-package safety becomes structural, not detected.** Package-scoped
292+
resolution (§3.3) keyed on the unique package id means two packages never
293+
mis-resolve a shared bare name, so the `O(every page/dashboard/flow)`
294+
per-item registration scan is retired. The remaining install-time work is a
295+
single `O(1)`-per-package namespace check for the object/table layer (§3.2).
254296
- **Object and UI metadata share one scope model.** The package container
255297
scopes both; the long-standing "objects are safe, UI metadata isn't"
256298
asymmetry disappears, with **zero artifact renames**.
@@ -264,27 +306,33 @@ as future work, not part of this decision.
264306

265307
## 5. Implementation phasing
266308

267-
Status legend: **[done]** shipped under the original proposal · **[proposed]**
268-
this revision.
309+
Status legend: **[done]** shipped · **[proposed]** not yet built ·
310+
**[deferred]** out of scope here.
269311

270-
- **[done]** Runtime same-package backstop: `MetadataCollisionError`,
271-
`isRealPackage`, `collisionPolicy` + `OS_METADATA_COLLISION`, the guard in
272-
`SchemaRegistry.registerItem`, `findOtherPackageOwner`
273-
(`packages/objectql/src/registry.ts`); tests
274-
`registry-cross-package-collision.test.ts` and
275-
`engine-cross-package-collision.test.ts`.
276312
- **[done]** Authoring lint: `naming/namespace-prefix` in `os lint` (warns on
277313
non-prefixed `app`/`page`/`dashboard`/`flow`/`action`/`report`/`dataset`;
278314
exempts the namespace-named app per ADR-0019 and `sys_` names; warning-only).
279-
- **[proposed] Phase 1 — install-time namespace gate.** In the package install
280-
path, refuse a package whose `manifest.namespace` is already owned by a
281-
different installed package; actionable error naming both packages. Backed by
282-
the installed-package registry (`InstalledPackage`).
283-
- **[proposed] Phase 2 — container-scoped resolution.** Thread the current
284-
package/app into `SchemaRegistry.getItem` (prefer-local) and into `objectui`
285-
metadata resolution (`MetadataProvider` / the `pages.find(name===)` sites),
286-
collapsing the frontend's first-match-wins mirror of the bug. Routes keyed on
287-
`packageId`; `app.label` carries display.
315+
- **[done] Phase 1 — install-time namespace gate.** `NamespaceConflictError` +
316+
the gate in `SchemaRegistry.installPackage` (refuses a package whose
317+
`manifest.namespace` is already owned by a different installed package;
318+
same-package reload and shareable `base`/`system`/`sys` exempt;
319+
`OS_METADATA_COLLISION=warn` downgrades). Tests:
320+
`registry-namespace-install-gate.test.ts`.
321+
- **[done] Phase 2 (backend) — package-scoped resolution.**
322+
`getItem(type, name, currentPackageId?)` prefers the current package's
323+
composite entry, keeping ADR-0005 overlay precedence; backward compatible.
324+
The per-item **cross-package throw is retired** (§3.4) — two distinct
325+
packages coexist on the same bare name. Tests:
326+
`registry-prefer-local-resolution.test.ts`; the original
327+
`*-cross-package-collision.test.ts` are rewritten from "throws" to
328+
"coexists + prefer-local resolves".
329+
- **[done] Phase 2 (frontend) — prefer-local in objectui.**
330+
`preferLocal(list, name, ownerPackageId)` keyed on `_packageId`, wired at the
331+
page/dashboard/report/header bare-name sites.
332+
- **[proposed] Phase 2 (frontend, remaining) — package-id routing.** Move the
333+
`/apps/:appName` segment to the package id and select the active app by
334+
`_packageId` (closing the app-selection ambiguity that `appName` leaves
335+
open). Optional: the per-tenant namespace alias (§3.6).
288336
- **[proposed] Phase 3 — qualified cross-package references.** Define and
289337
document the `<packageId>:<name>` reference form for the deliberate
290338
cross-package case (nav contributions, shared pages); resolution falls back
@@ -298,10 +346,10 @@ this revision.
298346
the missing package coordinate; its optimistic-concurrency `parentVersion`
299347
check already rejects a blind base-layer double-create with `ConflictError`.
300348
The genuinely *silent* path was the objectql `SchemaRegistry` read
301-
resolution — which §3.3 makes container-scoped.
349+
resolution — which §3.3 makes package-scoped.
302350
- `objectui` route inventory (for Phase 2): metadata reachable by name divides
303351
into (a) already-safe — `object`/`view` (kernel namespace), `component`
304352
(`:ns/:name` segment), `doc` (ADR-0046 authoring prefix), marketplace/package
305-
routes (already keyed on `packageId`); (b) container-scoped via this ADR —
353+
routes (already keyed on `packageId`); (b) package-scoped via this ADR —
306354
`page`/`dashboard`/`report`; (c) one-off — `app` (now keyed on `packageId`);
307355
(d) intentionally global — `action`, Studio's `metadata/:type/:name` admin.
Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* ADR-0048 — end-to-end: the cross-package collision guard fires through the
5-
* real `ObjectQL.registerApp` entry point (not just the registry unit), since
6-
* that is the choke point every installed package's metadata arrays flow
7-
* through. Uses a real engine + real registry (no mock) on purpose.
4+
* ADR-0048 §3.4 — end-to-end: two packages shipping the same bare-named page
5+
* coexist through the real `ObjectQL.registerApp` entry point (the choke point
6+
* every installed package's metadata arrays flow through). Uses a real engine +
7+
* real registry (no mock) on purpose. Package-scoped resolution keeps each
8+
* package's `home` reachable; there is no cross-package throw.
89
*/
910

1011
import { describe, it, expect } from 'vitest';
1112
import { ObjectQL } from './engine';
12-
import { MetadataCollisionError } from './registry';
1313

14-
describe('ObjectQL.registerApp — cross-package collision (ADR-0048)', () => {
15-
it('throws when a second package registers a bare-named page already owned by another', () => {
14+
describe('ObjectQL.registerApp — cross-package coexistence (ADR-0048 §3.4)', () => {
15+
it('lets a second package register a bare-named page already owned by another', () => {
1616
const engine = new ObjectQL();
1717
engine.registerApp({
1818
id: 'com.acme.crm',
@@ -24,34 +24,31 @@ describe('ObjectQL.registerApp — cross-package collision (ADR-0048)', () => {
2424
id: 'com.acme.hr',
2525
pages: [{ name: 'home', title: 'HR Home' }],
2626
}),
27-
).toThrowError(MetadataCollisionError);
27+
).not.toThrow();
2828
});
2929

30-
it('allows two packages to define same-named pages once namespaced apart', () => {
30+
it('resolves each package\'s page by package id', () => {
31+
const engine = new ObjectQL();
32+
engine.registerApp({ id: 'com.acme.crm', pages: [{ name: 'home', title: 'CRM Home' }] });
33+
engine.registerApp({ id: 'com.acme.hr', pages: [{ name: 'home', title: 'HR Home' }] });
34+
35+
expect(engine.registry.getItem<any>('page', 'home', 'com.acme.crm')?.title).toBe('CRM Home');
36+
expect(engine.registry.getItem<any>('page', 'home', 'com.acme.hr')?.title).toBe('HR Home');
37+
});
38+
39+
it('allows two packages with differently-named pages (trivially)', () => {
3140
const engine = new ObjectQL();
3241
expect(() => {
33-
engine.registerApp({
34-
id: 'com.acme.crm',
35-
pages: [{ name: 'crm_home', title: 'CRM Home' }],
36-
});
37-
engine.registerApp({
38-
id: 'com.acme.hr',
39-
pages: [{ name: 'hr_home', title: 'HR Home' }],
40-
});
42+
engine.registerApp({ id: 'com.acme.crm', pages: [{ name: 'crm_home', title: 'CRM Home' }] });
43+
engine.registerApp({ id: 'com.acme.hr', pages: [{ name: 'hr_home', title: 'HR Home' }] });
4144
}).not.toThrow();
4245
});
4346

4447
it('allows the same package to be re-registered (idempotent reload)', () => {
4548
const engine = new ObjectQL();
46-
engine.registerApp({
47-
id: 'com.acme.crm',
48-
pages: [{ name: 'home', title: 'v1' }],
49-
});
49+
engine.registerApp({ id: 'com.acme.crm', pages: [{ name: 'home', title: 'v1' }] });
5050
expect(() =>
51-
engine.registerApp({
52-
id: 'com.acme.crm',
53-
pages: [{ name: 'home', title: 'v2' }],
54-
}),
51+
engine.registerApp({ id: 'com.acme.crm', pages: [{ name: 'home', title: 'v2' }] }),
5552
).not.toThrow();
5653
});
5754
});

0 commit comments

Comments
 (0)