diff --git a/docs/adr/0048-cross-package-metadata-collision.md b/docs/adr/0048-cross-package-metadata-collision.md index 9d16094e06..bd6a56d6d0 100644 --- a/docs/adr/0048-cross-package-metadata-collision.md +++ b/docs/adr/0048-cross-package-metadata-collision.md @@ -8,6 +8,7 @@ the app-marketplace era is revised below. **Builds on**: [ADR-0003](./0003-package-as-first-class-citizen.md) (package as first-class citizen), [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay precedence), [ADR-0008](./0008-metadata-repository-and-change-log.md) (metadata repository, `MetaRef` identity), [ADR-0010](./0010-metadata-protection-model.md) (package provenance / `_packageId` stamping) **Consumers**: `@objectstack/objectql` (`SchemaRegistry.registerItem`, `ObjectQL.registerApp`, install path), `@objectstack/console` / `objectui` (routing + metadata resolution), package authors, CLI/CI install path, the app marketplace registry. **Surfaced by**: ADR-0046 review (doc naming) → generalised to all bare-named metadata → re-examined through the app-marketplace install lens. +**Addenda**: [2026-08-08 — publish-time / marketplace namespace exclusivity](#addendum-2026-08-08-publish-time--marketplace-namespace-exclusivity) (**Proposed**; additive to §3.2, supersedes nothing). --- @@ -353,3 +354,398 @@ Status legend: **[done]** shipped · **[proposed]** not yet built · routes (already keyed on `packageId`); (b) package-scoped via this ADR — `page`/`dashboard`/`report`; (c) one-off — `app` (now keyed on `packageId`); (d) intentionally global — `action`, Studio's `metadata/:type/:name` admin. + +--- + +## Addendum (2026-08-08): publish-time / marketplace namespace exclusivity + +> **Status of this addendum: Proposed.** It is **additive to §3.2** and +> **supersedes no text above** — §§1–6 stand verbatim, the install-time gate's +> semantics are unchanged, and §3.5 (namespace rename-on-install) stays +> deferred. What it adds is a **second, earlier checkpoint** in front of the +> install gate: a global `namespace → publisher` exclusive registration made at +> **marketplace publish time**. +> +> **Ruling this addendum executes** (maintainer-approved fleet decision, +> 2026-08-06, issue #1825 — quoted verbatim): +> +> 「准——立 ADR-0048 addendum,定 publish-time / marketplace 全局命名空间独占注册契约。 +> 起步口径:保留粒度从**纯 namespace** 起步(不含 version-range,复杂度不前置); +> 共享命名空间(base/system/sys)白名单、存量 grandfathering、install-time 反查 +> 平台注册表三个 open question 在 ADR 内逐条定。」 +> +> *(Approved — establish an ADR-0048 addendum defining the publish-time / +> marketplace global namespace exclusive-registration contract. Starting +> position: reservation granularity starts at the **bare namespace** (no +> version-range — do not front-load that complexity); the three open questions — +> shared-namespace whitelist (base/system/sys), grandfathering of existing +> packages, and install-time reverse lookup against the platform registry — are +> each settled inside the ADR.)* +> +> The same disposition also fixed: reservation granularity = pure namespace; +> shared-namespace whitelist aligned with the install gate's `base`/`system`/ +> `sys`; existing packages grandfathered first-come; rename/transfer stays +> **deferred**. This addendum honours all four. + +### A.0 Where the surface actually stands today (verified against `main`, 2026-08-08) + +Recording the measured state, because the whole addendum is a claim about a gap: + +- **Install time is the only enforcement point that exists.** + `SchemaRegistry.installPackage()` in `packages/objectql/src/registry.ts` + refuses a package whose `manifest.namespace` is already owned by a + *different* installed package (`NamespaceConflictError`), exempting shareable + platform namespaces via `isShareableNamespace()` (`RESERVED_NAMESPACES = + {base, system}` plus `sys`) and same-package reload, with + `OS_METADATA_COLLISION=warn` as the documented downgrade. Pinned by + `packages/objectql/src/registry-namespace-install-gate.test.ts`; surfaced on + the service contract as `PackageService`'s `namespaceConflicts` / + `conflicts` (`packages/spec/src/contracts/package-service.ts`). +- **Publish time has zero namespace surface — not partial, none.** + `PackageSchema` (`packages/spec/src/cloud/package.zod.ts`) carries + `manifestId`, `ownerOrgId`, `visibility`, `publisher`, and listing metadata, + and has **no `namespace` field at all**; `CreatePackageRequestSchema` does + not accept one; `objectstack package publish` + (`packages/cli/src/commands/package/publish.ts`) derives and transmits + `manifest_id` only. The namespace never leaves the artifact. +- **The marketplace review pipeline** (enterprise-side, outside this repo) + today checks the `manifest_id` **reserved-prefix** rule and nothing about + namespaces. Adding the namespace uniqueness check to it is exactly + **minimal version A** below. + +So the exposure is precise: two vendors can each publish a package claiming +namespace `crm`, both listings are valid, and the collision is only discovered +by the **tenant** — the one party who cannot fix it — at the moment they try to +install the second one. + +### A.1 Decision + +#### D1 — The reserved unit is the bare `namespace`. No version-range. + +The reservation record is keyed on the namespace string alone, as validated by +`manifest.namespace` today (`/^[a-z][a-z0-9_]{1,19}$/`, +`packages/spec/src/kernel/manifest.zod.ts`): + +``` +reservation := { + namespace: 'crm', // the key — globally unique + publisherOrgId: , + claimedByManifestId: 'com.acme.crm', // the manifest that first claimed it + claimedAt: , + grandfathered: false, // see D4 +} +``` + +Rationale on the three axes: + +- **Real business need (实际业务需求).** The thing that breaks is a physical + table name — `crm_account` — and a table name has no version axis. Two + versions of the *same* vendor's package never collide (same owner); two + vendors' packages collide at *every* version pair. A version-range dimension + would therefore be reserved capacity that no real conflict can consume. +- **Long-term soundness (长远合理性).** `(namespace, version-range)` would let + vendor A own `crm@1.x` and vendor B own `crm@2.x`. Since the install gate is + per-installation and version-blind, a tenant installing A@1 and B@2 still + collides — the finer key would grant reservations the enforcement layer + cannot honour, which is worse than no key at all. Granularity can only be + *widened* later (a range is a refinement of the whole); starting narrow is + the reversible direction. +- **AI-mistake prevention (防 AI 犯错).** One key, one owner, one answer to + "who owns `crm`?". A range-keyed registry has an interval-overlap predicate + at its heart, and an agent asked to "check the namespace is free" would have + to know that "free" is a function of the version being published. The bare + key makes the wrong answer unrepresentable. + +#### D2 — The reservation is held by the **publisher**, not by the package + +A publish is rejected when the namespace is reserved by a **different** +`publisherOrgId`. Within one publisher it is allowed, and merely records an +additional `claimedByManifestId` under the existing reservation. + +This is deliberate and is the only place the publish gate is *looser* than the +install gate. The reason is which conflicts are **resolvable by someone**: + +- *Cross-vendor* collision is unresolvable by any party — neither vendor can be + made to yield, and the tenant is the one who suffers. That is the case the + registry exists to make impossible, and it hard-fails. +- *Same-vendor* collision (a vendor publishing `com.acme.crm2` as the successor + to a retired `com.acme.crm`, reusing namespace `crm`) is entirely within one + party's control. Blocking it would permanently strand a vendor's own + namespace behind their own deprecated package, with no transfer flow to + escape through (transfer is deferred — §A.4). The install gate still refuses + to co-install the two, which is the correct outcome for a successor pair, and + the publish response carries a warning naming the sibling. + +#### D3 — Shared-namespace whitelist: exactly the install gate's set, and it is *unreservable* + +`base`, `system`, `sys` — i.e. `RESERVED_NAMESPACES ∪ {'sys'}`, the identical +set `isShareableNamespace()` already exempts. Two distinct rules apply to them, +and conflating the two is the trap: + +1. **They are never reserved and never conflict.** A publish carrying one of + them neither creates a reservation nor consults one, exactly mirroring the + install gate's exemption. This is required for correctness, not convenience: + `sys_*` objects are contributed by many packages by design + (`registerNamespace` is intentionally many-to-one), so an exclusivity check + over them would reject the platform's own composition. +2. **They are restricted by publisher tier, not by first-come.** Only + `publisher: 'objectstack'` (the first-party tier already in + `PackagePublisherSchema`) may publish a package whose namespace is in the + whitelist. Anyone else is rejected. Exemption from *exclusivity* must not + read as *open season*: `manifest.zod.ts` already states these are + "platform-reserved" and that `sys_`-prefixed object names belong to the + platform, and without rule 2 the whitelist would be the single cheapest way + for a third party to ship objects that shadow platform tables. + +Rationale on the three axes: **real need** — the platform genuinely ships many +packages under `sys`, so the whitelist must exist; **long-term soundness** — +deriving it from the same constant the install gate uses means the two gates +cannot drift into disagreeing about what is shareable, which is the drift the +issue's acceptance criterion ("compose without drift") names; **AI-mistake +prevention** — an agent generating a manifest that picks `system` as a +"neutral-sounding" namespace is refused at publish with a tier reason, instead +of silently acquiring shadowing rights. + +#### D4 — Grandfathering: first-come, frozen, and shrink-only + +When the registry is switched on, back-fill it rather than starting empty: + +1. For every namespace appearing in already-published marketplace packages, + award the reservation to the publisher of the **earliest published version** + carrying it (first-come). Ties break on `sys_package.created_at`, then on + `manifest_id` lexicographically, so the back-fill is deterministic and + re-runnable. +2. Every *other* publisher already shipping that namespace is recorded as a + **grandfathered claim** (`grandfathered: true`). A grandfathered claim + permits **new versions of the packages that already existed** under that + namespace, and permits nothing else — in particular it does **not** permit a + *new* package to take the namespace. +3. The grandfathered set is **closed at switch-on and shrink-only** thereafter. + No new entry can ever be created; entries leave when the package is + unlisted or migrates off the namespace. + +Rationale on the three axes: **real need** — retroactively invalidating a +published package breaks tenants who already installed it, and this addendum +must not create an outage to close a future hole; **long-term soundness** — +first-come is the only rule that needs no adjudicator, and a shrink-only +exception list is the same ratchet shape the repo uses everywhere else (an +exception that cannot grow eventually costs nothing); **AI-mistake +prevention** — the alternative rules all require judgement ("who deserves +`crm`?"), and a judgement call in a gate is a call an agent will make wrongly +and confidently. + +A grandfathered pair remains **un-co-installable** — the install gate says so +today and continues to. That is the pre-existing state being preserved, not a +regression introduced here. Note also the expected size: third-party publishing +is not open (§A.5), so at switch-on the corpus is first-party plus internal, and +the grandfathered set is expected to be empty or near-empty. If the back-fill +finds a non-trivial set, that is evidence the switch-on is late. + +#### D5 — Install-time cross-check of the platform registry: **advisory, fail-open, never authoritative** + +The contract is defined here; the implementation is deferred to the +enterprise-side card. + +`PackageService` gains an **optional** namespace-registry lookup port. When a +host has a platform connection (`@objectstack/cloud-connection`), the install +path MAY resolve `namespace → { publisherOrgId, grandfathered }` and compare it +against the incoming artifact's provenance. On mismatch it emits a **warning and +a trust signal** in the install surface — *"this package claims namespace `crm`, +which is reserved on the ObjectStack Marketplace by another publisher"* — and +**installs anyway**. On lookup failure, timeout, or no platform connection, it +**fails open silently**: no warning, no block, no log noise. + +This is a refusal to make the remote registry load-bearing, for three reasons: + +- **Local development and air-gapped installs must keep working.** §4's "Local + development is unaffected until install" is a load-bearing property of this + ADR, and a hard remote check would repeal it. +- **The local correctness property is already fully enforced locally.** The + invariant that matters — *no two installed packages in this installation + share a namespace* — is completely decided by Phase 1's in-process gate with + no network involved. The registry adds **provenance** ("who is the legitimate + owner of this name in the world"), which is a trust signal, not a correctness + one. A trust signal that can hard-fail an install turns a marketplace outage + into a total install outage across every tenant. +- **Sideloading is the case this addresses, and sideloading is a deliberate + act.** The user installing an artifact from outside the marketplace has + already stepped outside it; what they lack is *information*, and information + is what the advisory check gives them. + +Rationale on the three axes: **real need** — the only scenario this covers is a +sideloaded artifact squatting a marketplace name, which is a trust problem, not +a data-integrity one; **long-term soundness** — the advisory can be promoted to +blocking later behind an explicit host policy, whereas a blocking check cannot +be relaxed once tenants depend on it; **AI-mistake prevention** — "advisory, +fail-open" is stated here as the contract so an implementer cannot reasonably +read the deferred card as licence to add a network dependency to the install +path. + +#### D6 — The per-tenant alias (§3.6) is unaffected, and stays tenant-local + +The friendly alias `/apps/crm → com.acme.crm` is unchanged. Making it explicit, +because the issue asked how alias and exclusivity interact: + +- The alias is derived from the **installed** namespace, whose uniqueness within + the tenant is guaranteed by the local install gate — not by the global + reservation. +- The global registry is **never consulted to resolve a route**. Route + resolution stays offline and canonical-package-id-keyed (§3.1/§3.3). +- A tenant cannot alias a namespace it has not installed, and a global + reservation confers no alias anywhere. + +Global reservation and tenant alias therefore do not interact at all: one is a +publish-time authority record, the other a tenant-local presentation +convenience. That non-interaction is the design, not an oversight. + +### A.2 The publish-time check — minimal version A + +The pipeline gains exactly one check. Stated as an algorithm so the enterprise +implementation and this repo's gate cannot drift: + +``` +onPublish(manifestId, namespace, publisherOrgId, publisherTier): + + if (namespace is absent) -> allow # namespace is optional today + if (namespace ∈ {base, system, sys}) # D3 + return publisherTier === 'objectstack' + ? allow # no reservation written + : reject NAMESPACE_RESERVED_PLATFORM + + r := registry.lookup(namespace) # D1 + if (r is null) -> registry.claim(...); allow # first claim wins + if (r.publisherOrgId === publisherOrgId) # D2 + -> registry.addManifest(manifestId); allow (+warn if new manifestId) + -> reject NAMESPACE_ALREADY_RESERVED { namespace, ownerPublisher, since } +``` + +Notes that are part of the contract: + +- **`namespace` must travel with the publish payload.** It does not today + (§A.0), so `PackageSchema` / `CreatePackageRequestSchema` gain a `namespace` + field mirroring `manifest.namespace`, and the CLI reads it off the artifact + the way it already reads `manifest.id`. This is the one open-side schema + change the contract implies, and it is the first item of the deferred card — + without it, the enterprise gate has nothing to check. +- **Only `visibility: 'marketplace'` publishes are gated.** `private` and `org` + packages never enter the global namespace, are never checked, and never + create a reservation. A private package is protected by the install gate + alone, exactly as today. +- **The rejection is a first-class error code**, registered in the ADR-0112 + ledger (`packages/spec/src/api/error-code-ledger.zod.ts`) alongside the + existing `PACKAGE_PUBLISH_FAILED` / `MANIFEST_CONFLICT` family, so a client + can distinguish "your namespace is taken" from a generic publish failure. The + names above are the proposed spellings; registration lands with the + implementation card, not with this ADR. +- **The error must name the remedy**, on the precedent of + `NamespaceConflictError`'s message: which namespace, who holds it, since + when, and that the fix is to choose another namespace (the manifest field and + every object-name prefix) — not to retry. + +### A.3 Open / commercial boundary + +Aligned with the cloud ADR-0016 iron rule **强制免费、治理收费** (*mandatory +things are free; governance is paid*), as this repo already applies it in +ADR-0105 D12 — the split is **code vs. activation**, not code vs. code. + +**Open, in this repo (free):** +- `manifest.namespace` and its validation; the `namespace` field on the publish + payload schemas. +- The install-time gate, `NamespaceConflictError`, `isShareableNamespace()`, + the shareable-namespace constant, and `OS_METADATA_COLLISION=warn` — + unchanged. +- The advisory lookup **port** and its fail-open semantics (D5), plus the error + codes and this contract. + +**Enterprise / marketplace (governance, paid):** +- The reservation registry itself — storage, the publish-time gate, ownership + records, the D4 back-fill and grandfathered ledger, dispute handling, and any + future transfer administration. + +The iron rule is satisfied because the free half is the half that keeps an +installation **correct**: any deployment, on any tier, offline or not, is fully +protected from a namespace collision corrupting its schema, because the local +gate refuses the install. The paid half prevents a collision from being +*discovered late* and provides an operated global authority to adjudicate names +— a hosted governance service, which is precisely what 治理收费 designates. A +deployment without it is never unsafe, only less informed. + +### A.4 Non-goals (explicit) + +- **Version-range reservation granularity** — D1; not deferred pending design, + actively rejected as a starting position. +- **Namespace rename / ownership transfer** — remains **deferred**, as in the + original issue (「暂不考虑」) and reaffirmed in the 2026-08-06 disposition. A + reservation is created and released; it is never moved between publishers. + D2's publisher-scoped key is what keeps the absence of a transfer flow from + stranding a vendor's own name. +- **Namespace rename-on-install** — unchanged non-goal, §3.5. +- **Squatting policy and dispute adjudication** — operational marketplace + policy, not a protocol decision; out of scope for this ADR. +- **Any change to install-gate semantics**, its shareable set, its + same-package-reload exemption, or the `OS_METADATA_COLLISION=warn` downgrade. +- **Enforcement outside the marketplace.** Local dev, private registries, and + sideloading are not policed and are not intended to be (D5). + +### A.5 Activation precondition (hard) + +> **The `namespace → publisher` uniqueness check MUST land before third-party +> publishing opens.** It is an acceptance criterion for opening third-party +> publishing, not a follow-up to it. + +Recorded verbatim from the same issue thread: +「**三方 publisher 开放前必须完成 namespace→publisher 唯一性检查**」 (*the +namespace→publisher uniqueness check must be completed before third-party +publisher access opens*). + +The ordering is not stylistic. Every package published before the check exists +becomes, by D4, a **grandfathered** entry that the registry can never revoke — +so each day the gate is late permanently enlarges a set that is otherwise +shrink-only. While publishing is first-party only, that set is bounded by our +own discipline; the moment it is open, it is bounded by nothing. Conversely, +building the gate earlier than this buys nothing: with no third-party +publishers there is no cross-vendor collision to prevent, which is exactly why +the work sat on hold. This addendum therefore draws the **contract** now and +lets the enterprise-side implementation be scheduled against the opening date. + +### A.6 Phasing + +Status legend as in §5. + +- **[proposed] Phase A1 — carry the namespace to publish.** `namespace` on + `PackageSchema` / `CreatePackageRequestSchema`; `objectstack package publish` + reads it off the artifact manifest. Open-side, this repo. Prerequisite for + everything below. +- **[proposed] Phase A2 — the publish-time gate (minimal version A).** §A.2's + algorithm in the marketplace review pipeline, plus the reservation store. + Enterprise-side. **This is the item bound by §A.5.** +- **[proposed] Phase A3 — grandfathering back-fill.** D4's deterministic + back-fill, run once at switch-on, emitting the closed grandfathered set as a + reviewable artifact. Enterprise-side. +- **[deferred] Phase A4 — advisory install-time cross-check.** D5's optional + port and its fail-open advisory. Contract fixed here; implementation + scheduled independently, and explicitly *not* a precondition for A2. +- **[non-goal] rename / transfer of a reservation** — §A.4. + +### A.7 Consequences + +- **A namespace collision becomes the publisher's problem at publish time**, + where exactly one party can fix it cheaply (pick another namespace before + anything ships), instead of the tenant's problem at install time, where + nobody can. +- **Two gates, one vocabulary.** Both keyed on the bare namespace, both + exempting the same `base`/`system`/`sys` set from the same constant. The + issue's "compose without drift" criterion is met by construction rather than + by convention. +- **The gates are deliberately asymmetric in exactly one respect** (D2: + publisher-scoped vs package-scoped). This is documented rather than + accidental, and it is the loose direction — publish never accepts something + install would have to refuse *between different vendors*; it accepts one + vendor's successor package, which install correctly refuses to co-install. +- **The install path acquires no network dependency** (D5), so offline, local, + and air-gapped installs behave identically to today. +- **A grandfathered exception set exists but cannot grow** (D4), so the cost of + switching the registry on late is bounded at switch-on and decays thereafter. +- **Open-source deployments lose nothing.** They keep the gate that prevents + corruption; what they do not get is the global authority that would have told + them earlier.