From 711dba8d8d0d01829c6fc7ccf1a7482c9c4c3bf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 02:38:28 +0000 Subject: [PATCH 1/6] docs(adr): add ADR-0028 metadata naming & namespace isolation Propose retiring the hand-written namespace-prefix authoring rule (only objects are protected today; ~23 other metadata kinds collide silently, e.g. connectors last-wins-overwrite) in favor of: - namespace as an identity dimension (short authored names; identity = (namespace, type, name)) - physical table names derived at the storage boundary, invisible to authors/AI (inverting the existing StorageNameMapping pass-through) - namespace as an addressing segment at transport surfaces (data API, metadata API, generated GraphQL/OData/SDK/MCP) - app sandboxing: no cross-app references (security boundary); only app -> kernel references are legal - a single unified reserved kernel namespace (sys) whose contract is unified but whose object ownership is distributed across first-party capability plugins (single-owner-per-object), decomposing the platform-objects monolith Grounded in a codebase scan (current-state findings + kernel cross-reference graph) and mainstream platform practice (Salesforce 2GP / ServiceNow scoped apps / Dataverse). Includes a phased, non-breaking migration plan. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 --- ...metadata-naming-and-namespace-isolation.md | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 docs/adr/0028-metadata-naming-and-namespace-isolation.md diff --git a/docs/adr/0028-metadata-naming-and-namespace-isolation.md b/docs/adr/0028-metadata-naming-and-namespace-isolation.md new file mode 100644 index 000000000..08c90fc89 --- /dev/null +++ b/docs/adr/0028-metadata-naming-and-namespace-isolation.md @@ -0,0 +1,347 @@ +# ADR-0028: Metadata Naming & Namespace Isolation — Derived Physical Names, Namespace-Scoped Identity, and a Single Kernel Contract + +**Status**: Proposed (2026-06-01) +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0004](./0004-object-namespace-prefix.md) (object namespace prefix — *this ADR supersedes its hand-written-literal authoring rule*), [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0008](./0008-metadata-repository-and-change-log.md) (Repository · ChangeLog · Cache · Registry; `MetaRef = org/type/name`), [ADR-0010](./0010-metadata-protection-model.md) (protection model), [ADR-0019](./0019-app-as-consumer-unit.md) (app as the consumer-installable unit), [ADR-0025](./0025-plugin-package-distribution.md) (package distribution) +**Consumers**: `@objectstack/spec` (manifest + stack validators), `@objectstack/objectql` (`SchemaRegistry`, `StorageNameMapping`, ownership model), `@objectstack/plugins/driver-sql` (physical table derivation), `@objectstack/rest` + `@objectstack/api` (route + generated-surface naming), `@objectstack/services/service-automation` (connector registry), `@objectstack/services/service-ai` (tool registry), `@objectstack/platform-objects` (kernel object ownership), `@objectstack/cli` (`os validate`) + +--- + +## TL;DR + +Today only **one** of ~24 metadata kinds (`object`) is protected against +cross-package name collisions, and it is protected by the *wrong mechanism*: +authors hand-write the namespace prefix into every name (`crm_account`). Every +other kind — `flow`, `role`, `permission`, `connector`, `tool`, `webhook`, +`api`, `app`, `dashboard`, … — carries a bare machine name and collides silently +when two installed packages pick the same name (connectors literally +`logger.warn('… replaced')` and overwrite, last-wins). + +This ADR replaces the hand-written prefix with the mechanism every major +metadata platform actually uses, and extends collision-freedom to **all** kinds: + +1. **Namespace is an identity dimension, not a string baked into names.** Item + identity is `(namespace, type, name)`. Authors write **short** local names + (`task`, `send_email`); the platform owns the namespace. +2. **Physical names are derived, never authored.** The storage driver maps + `(namespace='todo', name='task') → table todo_task`. The prefix becomes a + storage detail the author and the AI never see — exactly as Salesforce, + ServiceNow, and Dataverse auto-prefix. +3. **Namespace is an addressing segment at every transport surface.** Data API + becomes `/api/v1/data/{namespace}/{object}`; generated GraphQL/OData/SDK/MCP + identifiers inject the namespace at generation. Uniqueness is enforced where + it physically matters (storage tables, route table, tool registry), not on + the authored name. +4. **Apps are sandboxed: no cross-app references.** The only legal + cross-boundary reference is **app → kernel**. This doubles as a security + boundary. +5. **The kernel is one unified, reserved namespace (`sys`) — one contract, but + ownership distributed across first-party plugins.** It is the platform's + public contract and the sole well-known import target (`sys.user`). Each + capability plugin owns its own `sys_*` objects (auth ← `sys_user`, audit ← + `sys_audit_log`, …) under a single-owner-**per-object** rule, rather than a + `platform-objects` monolith owning everything. Unification is of the + *contract*, not of the code package. + +Decision on the open question (kernel = unified `sys` vs domain-partitioned +sub-namespaces): **unified `sys`**, on two independent grounds — industry +practice and the measured cross-reference graph (below). + +--- + +## Context + +### The problem + +An ObjectStack instance installs many packages from the marketplace. A package +(`defineStack`) can contribute ~24 metadata collections. As install count grows, +name collisions across packages are inevitable — and they are currently +unmanaged for everything except objects. + +### Current-state findings (codebase scan) + +| Area | Finding | Location | +|:--|:--|:--| +| Prefix enforcement | `validateNamespacePrefix()` iterates **only `config.objects`** (`if (!ns || !config.objects) return`). The other ~23 collections are unchecked. | `spec/src/stack.zod.ts:459` | +| Authoring style | Object names are the **hand-written full literal** `crm_account`; docs explicitly forbid a `ns('task')` helper. | `spec/src/kernel/manifest.zod.ts:28-76` | +| Storage chokepoint | `StorageNameMapping.resolveTableName({name})` already exists, but is a **pass-through** (`todo_task → todo_task`, strips legacy `__`). Every SQL driver routes table names through it. | `spec/src/system/constants/system-names.ts:169`; `driver-sql/src/sql-driver.ts:610,1028` | +| Object identity | `MetaRef = (org, type, name)` and `SchemaRegistry` already model ownership + namespace. | `metadata-core/src/types.ts`; `objectql/src/registry.ts` | +| Ownership model | `own`/`extend` fully implemented: one owner enforced (`throw`), extenders merge by `priority` (owner 100, extender 200). **No package actually extends a `sys_` object today.** | `objectql/src/registry.ts:406-518`; `object.zod.ts:856-897` | +| Connector collisions | Re-registering a connector name only `logger.warn('… replaced')` then overwrites — **silent last-wins**. | `services/service-automation/src/engine.ts:441` | +| API routes | Route conflict detection exists with 4 strategies, but matches routes by **exact string** (`:id` vs `:userId` not detected). | `core/src/api-registry.ts` | +| Kernel namespace | `sys` is a **shared** namespace co-claimed by ~14 packages (`namespaceRegistry: Map>`); `RESERVED_NAMESPACES = {'base','system'}` does **not** include `sys`. | `objectql/src/registry.ts:13,346-389` | +| Kernel definitions | All `sys_*` objects are in fact **defined centrally in `platform-objects`**, even though `plugin-auth`/`service-job`/`service-settings` manifests each declare `namespace:'sys'` — ownership *declaration* is split from *definition*. | `platform-objects/src/**` | +| Boundary enforcement | "Apps may reference `sys_*` but never define them" is **documented intent only** — no validator enforces it; the `sys_` check only *exempts*, it does not *forbid*. | `manifest.zod.ts:66-70` | +| Kernel cross-refs | ~60 lookup fields across identity/audit/security/metadata/system (and `service-ai`'s `ai_conversations.user_id`) point at the **hub objects `sys_user` / `sys_organization`**. | scan, see §"Why unified" | + +### How mainstream metadata/low-code platforms name things + +| System | Package/app component names | Who writes the prefix | Kernel / standard objects | Kernel packageable? | Cross-scope reference | +|:--|:--|:--|:--|:--|:--| +| **Salesforce** (2GP) | `hpa__New_Field__c` | **Platform, automatically** — "you never add the namespace manually" | `Account`,`Contact`,`User` — **no prefix** | **No** (standard objects can't be packaged) | Standard objects globally referenceable | +| **ServiceNow** (scoped app) | `x_acme_app_request` (author writes `request`) | **Platform auto-prepends** `x___` | `sys_*` reserved system tables | Platform-owned | Cross-scope access is **governed** (ACL) | +| **Dataverse** (solution/publisher) | `cr8a3_animal` (author writes `animal`) | **Platform, per publisher prefix** | Microsoft standard tables — no publisher prefix | Microsoft-owned | Solution-isolated | + +**Two laws every major platform follows, that ObjectStack currently violates:** + +1. **The prefix is always platform-derived, never hand-authored.** ObjectStack's + hand-written literal is the outlier. The author always writes the short name. +2. **The kernel is a reserved, single-owner, *flat* namespace that cannot be + re-defined by packages and is the one global reference target.** None of the + three partition the kernel into per-domain sub-namespaces. + +--- + +## Decision + +### D1 — Namespace is identity context; authored names are short + +Item identity becomes the tuple `(namespace, type, name)`. `name` is unique only +within `(namespace, type)`, not globally. `defineStack` injects the manifest +`namespace` as ambient context; **authors never repeat it**. Two packages each +defining `flow send_email` are no longer in conflict — keys are +`(crm, flow, send_email)` and `(todo, flow, send_email)`. + +This generalizes `MetaRef` (`org/type/name`) with a `namespace` dimension and +applies to **all** collections, not just objects. + +### D2 — Physical names are derived at the storage boundary (invisible above) + +Invert `StorageNameMapping.resolveTableName` from pass-through to derivation: + +```ts +// before: resolveTableName({ name: 'todo_task' }) -> 'todo_task' +// after: resolveTableName({ namespace: 'todo', name: 'task' }) -> 'todo_task' +// + honor an explicit physicalName/tableName override when binding +// to a pre-existing external table (default-derive, override-allowed) +``` + +Column names already follow this pattern (`resolveColumnName` honors +`columnName`); tables now do too. The metadata layer — and the AI authoring it — +sees only short names everywhere object names appear (definition, view +`data.object`, dashboard, report, flow/hook references, app navigation, seed +`externalId`, translation keys, permissions, sharing). + +**This is the concrete answer to the AI-hallucination concern.** The original +literal-prefix rule existed to avoid two writing styles (`task` vs `crm_task`) +that made AI guess wrong. Deriving the prefix at storage removes one style +entirely: at the authoring layer there is exactly one form (`task`). See +§"Leak-points" for the conditions that keep it airtight. + +### D3 — Namespace is an addressing segment at every transport surface + +The namespace must reappear **once, at each transport boundary**, as a +*structured segment*, not concatenated into a name — then be resolved away +before going deeper. One form per layer. + +**Explicit routes** (add a namespace segment / qualifier): + +- `/api/v1/data/{namespace}/{object}` (+ `/:id`, `/export`, `/:id/shares`, query, aggregate) +- `/api/v1/metadata/{namespace}/{type}/{name}` (`MetaRef` gains the dimension) +- reports, action/flow invocation, views/pages addressed by name +- inbound webhooks, connector invocation, job triggers — id qualified as `ns.name` + +**Generated surfaces** (inject namespace at generation — *easy to miss, must be +done together*): object names are the source identifier for GraphQL types, +OData EntitySets, OpenAPI `operationId`/schema names, the client SDK, and +LLM-facing MCP tool names. Two packages' `Task` types collide unless the +generator namespaces them. Generated id = `{namespace}.{name}` (or per-namespace +schema), resolved back to `(ns, name)` at runtime. + +**Resolution pipeline:** `route /data/todo/task` → handler resolves `(todo, task)` +→ driver derives physical `todo_task` → response payloads use short names. + +### D4 — Apps are sandboxed: no cross-app references (security boundary) + +A `type: app` package may reference its own metadata (short names) and the +kernel (qualified), but **may not reference metadata owned by another app**. +This collapses the only remaining "two writing styles" risk to a single, +allow-listed case (kernel imports) and simultaneously enforces a tenant-style +isolation boundary between marketplace apps. + +### D5 — The kernel is one unified, reserved namespace (`sys`) with object ownership distributed across first-party plugins + +Separate two things the current code conflates: the kernel **contract** +(namespace, reference surface, stability guarantee) and the kernel +**code ownership** (which package defines each object). The contract is +*unified*; ownership is *distributed*. The kernel object set is the platform's +**public contract** and the sole cross-boundary reference target. It is: + +- **One reserved namespace** — `sys` — added to `RESERVED_NAMESPACES`. Apps + reference it via a single well-known import (`sys.user`, `sys.organization`), + with no per-package dependency declaration (like `std`). +- **Shared namespace, single owner *per object*.** The invariant is + single-owner-per-object-name (already enforced: a second `own` throws), **not** + single-owner-per-namespace. So `sys` is co-contributed by many first-party + packages while every object name has exactly one owner — collision-safe via the + object-level `own` check plus the install-time identifier registry (D6). +- **Object ownership follows the capability plugin** (honoring the + microkernel/plugin-extensibility philosophy: a first-party feature is still a + plugin that ships *its own data model + behavior*). `plugin-auth` owns + `sys_user`/`sys_session`/`sys_organization`; `plugin-audit` owns + `sys_audit_log`; `service-job` owns `sys_job`; `plugin-email` owns `sys_email`; + etc. This corrects today's smell where these plugins *declare* `namespace:'sys'` + but the objects are *defined* in the `platform-objects` monolith + (ownership-declaration split from definition). +- **`platform-objects` is decomposed.** It shrinks to the *core-mandatory* slice + — the hub objects everything references (`sys_user`, `sys_organization`), + `sys_metadata`, and shared base/mixins — or dissolves into the capability + plugins entirely, leaving at most a re-export facade. Everything optional + (audit, jobs, email, approvals, sharing, AI, webhooks) becomes a plugin that + owns its `sys_*` objects. +- **Hub + load-order, not centralization.** The two real forces that historically + drove centralization are addressed without it: (1) the hub objects + `sys_user`/`sys_organization` are declared *core-mandatory* and other plugins + declare a `dependency` on them; (2) load order is sequenced via the existing + `dependencies` / plugin-loading `loadOrder` so an owner registers before its + referencers — which is exactly the plugin system's job. +- **Reference-but-not-define, enforced structurally** — `registerObject` + rejects a `scope:'project'`/`type:'app'` package that tries to `own` (or + define) any object in a reserved kernel namespace. The `sys_`-prefix *exemption* + becomes a *prohibition* for apps. (Apps still `extend` kernel objects via + `objectExtensions` — the supported, arbitrated path.) +- **Scattered quasi-kernel namespaces decided per-object** — `ai`, `mail`, + `branding`, `prefs`, `feat`, `storage`, `knowledge`, `feature_flags`: each + object is classified as *kernel contract* (owned by its capability plugin, + contributing into `sys`) or *ordinary package* (prefixed, not + app-referenceable). `nope` is deleted. + +#### Why unified contract (`sys`), not domain-partitioned namespaces + +(This is about the *namespace/reference surface*, independent of D5's distributed +*ownership*: ownership is per-plugin either way.) + +1. **Industry:** Salesforce / ServiceNow / Dataverse all keep the kernel flat + and single-owner; per-domain partitioning is how they split *apps*, not the + kernel. Exposing `identity`/`audit`/`automation`/`ai` as separate imports + leaks internal package structure into the public contract and multiplies what + an app author / AI must memorize — the opposite of the low-code goal. +2. **Measured cross-reference graph:** ~60 kernel lookups converge on the hub + objects `sys_user` and `sys_organization`, referenced from *every* domain + (identity, audit, security, metadata, system, even `service-ai`). Partitioning + would turn nearly every internal kernel reference into a cross-namespace + qualified reference — manufacturing friction inside the kernel itself. + +### D6 — Authoring & install enforcement (the two new chokepoints) + +- **Author time** (`defineStack`, `os validate`): generalize + `validateNamespacePrefix` into a *namespace-scope* validator over **all** + collections — names must be bare short names (no `ns_` prefix, no `__`), + references resolve within the package namespace or to a qualified + `sys.x` / (forbidden) cross-app ref. Early failure with the exact fix string. +- **Install time** (package registry): register every + `(namespace, type, name)` plus every derived transport key (route, + connector id, tool name, webhook). The only true conflict left is **two + packages claiming the same namespace** — already modeled by + `NamespaceConflictError`. Catches binary artifacts that bypass `defineStack`. +- **Runtime registries** unify their duplicate semantics: the connector registry + stops silently overwriting (`engine.ts:441`) and uses the same conflict policy + as objects/routes. + +--- + +## Leak-points that must be sealed for D2 to be airtight + +The derived-prefix model only holds if the physical name never re-surfaces to +the author/AI as a second style: + +1. **Raw SQL / native analytics.** `service-analytics`'s `native-sql-strategy` + and any cube/report that can reference physical tables must go through name + resolution — no hand-written `FROM todo_task`. +2. **External / legacy tables.** An object bound to a pre-existing fixed table + name needs a `tableName`/`physicalName` override — derivation is the + *default*, not mandatory (mirrors `columnName`). +3. **Cross-package references.** Within a package: short name. To the kernel: + qualified `sys.x`. Cross-app: forbidden (D4). This is the one place a + qualified form appears, and it is a single deterministic rule (like an + `import`), not an arbitrary second style. +4. **Diagnostics.** Driver errors/logs will show `todo_task`; this is read-only + and not authored, so it does not reintroduce ambiguity (cosmetic mapping only). + +--- + +## Migration plan (phased, non-breaking until the flip) + +ObjectStack object names today *are* the special case where `name == namespace + +'_' + short` and storage is a pass-through. That makes a staged migration +possible. + +- **Phase 0 — Dual-read storage.** Teach `resolveTableName` to accept both + `(namespace, short)` and a legacy already-prefixed `name`, deriving the same + physical table. No behavior change; unblocks everything else. +- **Phase 1 — Validators (additive, warn-only).** Generalize the namespace-scope + validator across all collections and add the install-time identifier registry, + emitting warnings (not errors) for collisions and for hand-written prefixes. +- **Phase 2 — Kernel re-attribution & decomposition.** Move each `sys_*` object's + ownership to its capability plugin (`plugin-auth` ← identity, `plugin-audit` ← + audit, `service-job` ← jobs, …); designate `sys_user`/`sys_organization`/ + `sys_metadata` as core-mandatory and wire `dependencies` + `loadOrder`; shrink + or dissolve `platform-objects`. Add `sys` to `RESERVED_NAMESPACES`; enforce the + app-cannot-define-kernel rule (apps may still `extend`). Classify the scattered + `ai`/`mail`/… objects; delete `nope`. Invariant throughout: + single-owner-per-object. +- **Phase 3 — Transport segments.** Introduce `/data/{namespace}/…` routes and + namespaced generated surfaces (GraphQL/OData/SDK/MCP) **side-by-side** with the + current ones; deprecate the old shapes. +- **Phase 4 — Authoring flip + codemod.** Switch authoring to short names; ship a + codemod that strips redundant `ns_` prefixes from authored metadata and + references. Flip validators warn→error. Connector registry adopts the unified + conflict policy. +- **Phase 5 — Remove legacy.** Drop dual-read and the legacy route/surface shapes + after the deprecation window. + +--- + +## Consequences + +**Positive** + +- Cross-package collisions for **all** metadata kinds disappear *by construction* + (tuple identity); the problem domain collapses from "23 kinds each need + collision handling" to "1 namespace-ownership check." +- Authoring matches the industry norm (short names, platform-derived physical + names) and the AI-context goal; the hand-written-prefix outlier is retired. +- Connector silent-overwrite and route exact-match gaps are folded into one + consistent conflict policy. +- The kernel becomes an explicit, enforced, single public contract; the + reference-vs-define asymmetry is structural, not by convention. +- App-to-app isolation gives a real security boundary for marketplace packages. + +**Negative / costs** + +- A large, cross-cutting refactor (spec validators, registry, SQL driver, + REST/API generators, connector + AI registries, `platform-objects`, CLI). +- Reintroduces a *qualified reference* form (`sys.x`) — the very thing ADR-0004 + avoided — but now as one deterministic, allow-listed rule rather than an + arbitrary alternative, and only for kernel/cross-boundary refs. +- Requires a logical→physical mapping to be honored on **every** data path; any + raw-SQL escape hatch is a correctness hazard (see Leak-points). +- Migration touches every existing package and artifact; needs the codemod and a + deprecation window. + +**Neutral / open** + +- Exact qualifier syntax for kernel refs (`sys.user` vs `sys:user`) — to settle + in implementation. +- Whether `field`-level names need any transport treatment beyond `columnName` + (currently believed no — fields are object-scoped). +- Per-namespace GraphQL schema stitching vs type-name prefixing — generator + detail for Phase 3. + +--- + +## Alternatives considered + +- **A. Extend the hand-written literal prefix to all 23 kinds.** Consistent and + zero-resolver, but doubles down on the outlier authoring style, is verbose, and + permanently welds name to physical key. Rejected as the long-term model + (it is the current stopgap). +- **B. Pure logical scoping with no derived physical name.** Rejected for + objects only — the database requires globally-unique table names, so objects + still need a derived physical name (D2). Adopted for every *non-object* kind, + where uniqueness is needed only at the transport/addressing layer (D3). +- **C. Detect-and-resolve at install time only (generalize the API-registry + strategy).** Useful as a safety net (kept as part of D6) but insufficient + alone — it treats the symptom and leaves runtime addressing ambiguous. +- **Kernel option (b): domain-partitioned sub-namespaces.** Rejected per + §"Why unified". From a09fb8b1365de95fcbda76cba4ae335ada80bd8e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 02:40:41 +0000 Subject: [PATCH 2/6] docs(adr): fix ADR-0028 broken cross-reference ADR-0004 is "Cloud Control Plane", not the object-namespace-prefix rule (which lives only in manifest.zod.ts / stack.zod.ts, with no standalone ADR). Replace the broken ./0004-object-namespace-prefix.md link with a Supersedes note pointing at the actual source files. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 --- docs/adr/0028-metadata-naming-and-namespace-isolation.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/adr/0028-metadata-naming-and-namespace-isolation.md b/docs/adr/0028-metadata-naming-and-namespace-isolation.md index 08c90fc89..310d1b0f3 100644 --- a/docs/adr/0028-metadata-naming-and-namespace-isolation.md +++ b/docs/adr/0028-metadata-naming-and-namespace-isolation.md @@ -2,7 +2,8 @@ **Status**: Proposed (2026-06-01) **Deciders**: ObjectStack Protocol Architects -**Builds on**: [ADR-0004](./0004-object-namespace-prefix.md) (object namespace prefix — *this ADR supersedes its hand-written-literal authoring rule*), [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0008](./0008-metadata-repository-and-change-log.md) (Repository · ChangeLog · Cache · Registry; `MetaRef = org/type/name`), [ADR-0010](./0010-metadata-protection-model.md) (protection model), [ADR-0019](./0019-app-as-consumer-unit.md) (app as the consumer-installable unit), [ADR-0025](./0025-plugin-package-distribution.md) (package distribution) +**Supersedes**: the hand-written object-namespace-prefix authoring rule documented in `packages/spec/src/kernel/manifest.zod.ts` (the `namespace` field) and enforced by `validateNamespacePrefix()` in `packages/spec/src/stack.zod.ts` — there is no standalone ADR for that rule today. +**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0008](./0008-metadata-repository-and-change-log.md) (Repository · ChangeLog · Cache · Registry; `MetaRef = org/type/name`), [ADR-0010](./0010-metadata-protection-model.md) (protection model), [ADR-0019](./0019-app-as-consumer-unit.md) (app as the consumer-installable unit), [ADR-0025](./0025-plugin-package-distribution.md) (package distribution) **Consumers**: `@objectstack/spec` (manifest + stack validators), `@objectstack/objectql` (`SchemaRegistry`, `StorageNameMapping`, ownership model), `@objectstack/plugins/driver-sql` (physical table derivation), `@objectstack/rest` + `@objectstack/api` (route + generated-surface naming), `@objectstack/services/service-automation` (connector registry), `@objectstack/services/service-ai` (tool registry), `@objectstack/platform-objects` (kernel object ownership), `@objectstack/cli` (`os validate`) --- @@ -311,9 +312,9 @@ possible. - A large, cross-cutting refactor (spec validators, registry, SQL driver, REST/API generators, connector + AI registries, `platform-objects`, CLI). -- Reintroduces a *qualified reference* form (`sys.x`) — the very thing ADR-0004 - avoided — but now as one deterministic, allow-listed rule rather than an - arbitrary alternative, and only for kernel/cross-boundary refs. +- Reintroduces a *qualified reference* form (`sys.x`) — the very thing the + hand-written-literal rule avoided — but now as one deterministic, allow-listed + rule rather than an arbitrary alternative, and only for kernel/cross-boundary refs. - Requires a logical→physical mapping to be honored on **every** data path; any raw-SQL escape hatch is a correctness hazard (see Leak-points). - Migration touches every existing package and artifact; needs the codemod and a From d96a28d84ed4fccdc6b34879ae4d6e3133942bd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 02:46:39 +0000 Subject: [PATCH 3/6] docs(adr): breakage-controlled migration plan for ADR-0028 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the migration section around three compatibility mechanisms so existing templates are never broken in a flag-day cutover: - per-package `namingMode: 'literal' | 'short'` manifest flag (old and new packages coexist in one instance; migration is opt-in per package) - idempotent, namespace-aware `resolveTableName` (dual-read) so adding derivation does not turn `crm_account` into `crm_crm_account` - sealed artifacts are never force-republished (codemod rewrites source templates only) Each phase (P0 foundations → P5 remove-legacy) now has an explicit exit gate; the only breaking step (P4) is per-package opt-in and driven by an `os migrate namespace` codemod. Kernel refactor (P2) is decoupled and template-transparent. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 --- ...metadata-naming-and-namespace-isolation.md | 107 +++++++++++++----- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/docs/adr/0028-metadata-naming-and-namespace-isolation.md b/docs/adr/0028-metadata-naming-and-namespace-isolation.md index 310d1b0f3..893d032d6 100644 --- a/docs/adr/0028-metadata-naming-and-namespace-isolation.md +++ b/docs/adr/0028-metadata-naming-and-namespace-isolation.md @@ -261,35 +261,84 @@ the author/AI as a second style: --- -## Migration plan (phased, non-breaking until the flip) - -ObjectStack object names today *are* the special case where `name == namespace + -'_' + short` and storage is a pass-through. That makes a staged migration -possible. - -- **Phase 0 — Dual-read storage.** Teach `resolveTableName` to accept both - `(namespace, short)` and a legacy already-prefixed `name`, deriving the same - physical table. No behavior change; unblocks everything else. -- **Phase 1 — Validators (additive, warn-only).** Generalize the namespace-scope - validator across all collections and add the install-time identifier registry, - emitting warnings (not errors) for collisions and for hand-written prefixes. -- **Phase 2 — Kernel re-attribution & decomposition.** Move each `sys_*` object's - ownership to its capability plugin (`plugin-auth` ← identity, `plugin-audit` ← - audit, `service-job` ← jobs, …); designate `sys_user`/`sys_organization`/ - `sys_metadata` as core-mandatory and wire `dependencies` + `loadOrder`; shrink - or dissolve `platform-objects`. Add `sys` to `RESERVED_NAMESPACES`; enforce the - app-cannot-define-kernel rule (apps may still `extend`). Classify the scattered - `ai`/`mail`/… objects; delete `nope`. Invariant throughout: - single-owner-per-object. -- **Phase 3 — Transport segments.** Introduce `/data/{namespace}/…` routes and - namespaced generated surfaces (GraphQL/OData/SDK/MCP) **side-by-side** with the - current ones; deprecate the old shapes. -- **Phase 4 — Authoring flip + codemod.** Switch authoring to short names; ship a - codemod that strips redundant `ns_` prefixes from authored metadata and - references. Flip validators warn→error. Connector registry adopts the unified - conflict policy. -- **Phase 5 — Remove legacy.** Drop dual-read and the legacy route/surface shapes - after the deprecation window. +## Migration plan (phased, breakage-controlled) + +The risk this plan manages is **breaking existing authored stacks/templates** — +today every template names objects with the hand-written literal (`crm_account`) +and references it everywhere. The strategy is **no flag day**: each package +migrates on its own schedule and the old and new forms coexist in the same +running instance, until the legacy form is finally removed. + +### Three compatibility mechanisms every phase relies on + +1. **Per-package naming mode** — a manifest field + `namingMode: 'literal' | 'short'`. Default stays `literal` through Phase 3 + (existing templates work untouched); new packages may opt into `short`; the + runtime supports **both simultaneously**, so old and new packages share one DB + and one instance. This makes migration *per-package and opt-in* rather than a + global cutover. +2. **Idempotent physical-name resolution (dual-read)** — `resolveTableName` + becomes: name already carries the `{namespace}_` prefix → treat as + already-qualified, do not re-prefix (legacy packages); bare short name → + derive (new packages). Both map to the same physical table, so introducing + derivation in Phase 0 does **not** turn `crm_account` into `crm_crm_account`. +3. **Sealed artifacts are never force-republished** — already-installed packages + keep their literal-named sealed artifacts and resolve via dual-read; the + codemod rewrites only **source templates**. Runtime keeps reading old artifacts + unaffected. + +### Phases (each is additive, reversible, and gated by an explicit exit criterion) + +- **Phase 0 — Foundations (internal, zero behavior change).** Add `namespace` as + a first-class dimension on `MetaRef` / registry keys (legacy derives it from + the prefix). Make `resolveTableName` idempotent + namespace-aware. Add the + `tableName`/`physicalName` override escape hatch. New capability lies dormant; + default path unchanged. + *Exit:* full suite green; `examples/app-crm` unchanged and passing. +- **Phase 1 — Conflict visibility (warn-only).** Install-time identifier registry + for `(namespace, type, name)` + transport keys (route/connector/tool) emits + **warnings** on collision; generalize the author-time validator across all + collections as an opt-in lint; upgrade the connector silent-overwrite to a loud + warning. Nothing is blocked. + *Exit:* run across all templates and produce a collision report that calibrates + Phase 4 scope. +- **Phase 2 — Kernel re-attribution & decomposition (first-party, app-invisible).** + Move each `sys_*` object's ownership to its capability plugin (`plugin-auth` ← + identity, `plugin-audit` ← audit, `service-job` ← jobs, …); designate + `sys_user`/`sys_organization`/`sys_metadata` core-mandatory and wire + `dependencies` + `loadOrder`; shrink/dissolve `platform-objects`. Add `sys` to + `RESERVED_NAMESPACES`; enforce the app-cannot-define-kernel rule (warn→error; + apps may still `extend`). Classify the scattered `ai`/`mail`/… objects; delete + `nope`. Invariant throughout: single-owner-per-object. Templates only + *reference* `sys_*`, so resolution is unchanged. + *Exit:* kernel objects resolve identically; identity/audit tests green. +- **Phase 3 — New transport surfaces (dual-serve, additive).** Introduce + `/api/v1/data/{namespace}/{object}` and namespaced generated GraphQL/OData/SDK/ + MCP **alongside** the current shapes; mark the old ones deprecated. Existing + routes and clients keep working. + *Exit:* both old and new contracts pass their tests. +- **Phase 4 — Authoring flip + codemod (the one breaking step, mechanized & + per-package).** Ship `os migrate namespace`: rewrites a template from + `crm_account` to short `account` + manifest `namespace`, updating every + reference (objects, views, flows, hooks, app nav, seed `externalId`, + translations, permissions, sharing) and flipping its `namingMode` to `short`. + Author-time validator goes warn→error for `short` packages; connector registry + adopts the unified conflict policy. Breakage is confined to the moment a package + opts into `short` and is performed automatically; packages still on `literal` + keep working. + *Exit:* codemod is idempotent and verified on `app-crm`; author→compile→run + round-trip green; legacy sealed artifacts still load. +- **Phase 5 — Remove legacy.** After a deprecation window: drop dual-read (short + form only), remove deprecated routes/generated surfaces, and stop accepting the + literal prefix. Only affects packages that never migrated — warned several + releases earlier. + *Exit:* zero first-party legacy usage; telemetry shows external migration done. + +**Decoupling note:** P0–P3 are all additive (independently mergeable and +reversible); the kernel refactor (P2) is transparent to templates and can land +and be validated independently of the naming flip (P4). The only true breaking +change, P4, is per-package opt-in and codemod-driven — there is never a moment +where unmigrated templates all break at once. --- From 7539b85b871e8667de0cfb860934cfc5c6530ed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 02:54:49 +0000 Subject: [PATCH 4/6] docs(adr): add ADR-0029 kernel object ownership; slim ADR-0028 P2 to reference it Carve the kernel-decomposition concern out of ADR-0028 into its own ADR: first-party capabilities become plugins that own their sys_* objects + behavior (correcting the platform-objects monolith where plugins declare namespace:'sys' but the objects are defined centrally). ADR-0029: - small core (identity/org hub + metadata) vs capability plugins (audit/jobs/email/approvals/sharing/ai/webhooks) that each own their objects - shared reserved `sys` namespace, single-owner-PER-OBJECT (not per-namespace, not a monolith owner) - hub + dependencies/loadOrder instead of centralization - decompose platform-objects behind a re-export facade - template-transparent, independently shippable, sequenced BEFORE the 0028 naming flip; phased K0-K4 with exit gates ADR-0028: Phase 2 now delegates to ADR-0029; header gains it as a prerequisite; D5 points to ADR-0029 as the authoritative source for the ownership mechanics (0028 keeps only the naming/contract decision). https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 --- ...metadata-naming-and-namespace-isolation.md | 28 +- ...ship-and-platform-objects-decomposition.md | 239 ++++++++++++++++++ 2 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md diff --git a/docs/adr/0028-metadata-naming-and-namespace-isolation.md b/docs/adr/0028-metadata-naming-and-namespace-isolation.md index 893d032d6..db0b3410e 100644 --- a/docs/adr/0028-metadata-naming-and-namespace-isolation.md +++ b/docs/adr/0028-metadata-naming-and-namespace-isolation.md @@ -3,7 +3,7 @@ **Status**: Proposed (2026-06-01) **Deciders**: ObjectStack Protocol Architects **Supersedes**: the hand-written object-namespace-prefix authoring rule documented in `packages/spec/src/kernel/manifest.zod.ts` (the `namespace` field) and enforced by `validateNamespacePrefix()` in `packages/spec/src/stack.zod.ts` — there is no standalone ADR for that rule today. -**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0008](./0008-metadata-repository-and-change-log.md) (Repository · ChangeLog · Cache · Registry; `MetaRef = org/type/name`), [ADR-0010](./0010-metadata-protection-model.md) (protection model), [ADR-0019](./0019-app-as-consumer-unit.md) (app as the consumer-installable unit), [ADR-0025](./0025-plugin-package-distribution.md) (package distribution) +**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0008](./0008-metadata-repository-and-change-log.md) (Repository · ChangeLog · Cache · Registry; `MetaRef = org/type/name`), [ADR-0010](./0010-metadata-protection-model.md) (protection model), [ADR-0019](./0019-app-as-consumer-unit.md) (app as the consumer-installable unit), [ADR-0025](./0025-plugin-package-distribution.md) (package distribution), [ADR-0029](./0029-kernel-object-ownership-and-platform-objects-decomposition.md) (**prerequisite** — kernel object ownership; D5/D6 below assume the kernel is properly owned per ADR-0029) **Consumers**: `@objectstack/spec` (manifest + stack validators), `@objectstack/objectql` (`SchemaRegistry`, `StorageNameMapping`, ownership model), `@objectstack/plugins/driver-sql` (physical table derivation), `@objectstack/rest` + `@objectstack/api` (route + generated-surface naming), `@objectstack/services/service-automation` (connector registry), `@objectstack/services/service-ai` (tool registry), `@objectstack/platform-objects` (kernel object ownership), `@objectstack/cli` (`os validate`) --- @@ -165,8 +165,12 @@ isolation boundary between marketplace apps. Separate two things the current code conflates: the kernel **contract** (namespace, reference surface, stability guarantee) and the kernel **code ownership** (which package defines each object). The contract is -*unified*; ownership is *distributed*. The kernel object set is the platform's -**public contract** and the sole cross-boundary reference target. It is: +*unified* (owned by this ADR); ownership is *distributed* (the mechanics — +re-attribution, decomposition, load-order — are specified by +**[ADR-0029](./0029-kernel-object-ownership-and-platform-objects-decomposition.md)**; +the bullets below summarize only what the naming model relies on). The kernel +object set is the platform's **public contract** and the sole cross-boundary +reference target. It is: - **One reserved namespace** — `sys` — added to `RESERVED_NAMESPACES`. Apps reference it via a single well-known import (`sys.user`, `sys.organization`), @@ -302,16 +306,14 @@ running instance, until the legacy form is finally removed. warning. Nothing is blocked. *Exit:* run across all templates and produce a collision report that calibrates Phase 4 scope. -- **Phase 2 — Kernel re-attribution & decomposition (first-party, app-invisible).** - Move each `sys_*` object's ownership to its capability plugin (`plugin-auth` ← - identity, `plugin-audit` ← audit, `service-job` ← jobs, …); designate - `sys_user`/`sys_organization`/`sys_metadata` core-mandatory and wire - `dependencies` + `loadOrder`; shrink/dissolve `platform-objects`. Add `sys` to - `RESERVED_NAMESPACES`; enforce the app-cannot-define-kernel rule (warn→error; - apps may still `extend`). Classify the scattered `ai`/`mail`/… objects; delete - `nope`. Invariant throughout: single-owner-per-object. Templates only - *reference* `sys_*`, so resolution is unchanged. - *Exit:* kernel objects resolve identically; identity/audit tests green. +- **Phase 2 — Kernel ownership (delegated to ADR-0029).** Kernel re-attribution, + `platform-objects` decomposition, `sys` reservation, and the + app-cannot-define-kernel boundary are owned by **[ADR-0029](./0029-kernel-object-ownership-and-platform-objects-decomposition.md)** + and sequenced **first** (it is template-transparent and independently + shippable). ADR-0028 only relies on its outcome — reserved `sys`, + single-owner-per-object, apps reference-but-not-define. This phase is a + dependency checkpoint, not new work here. + *Exit:* ADR-0029 K0–K3 complete (reserved `sys`, kernel objects single-owned). - **Phase 3 — New transport surfaces (dual-serve, additive).** Introduce `/api/v1/data/{namespace}/{object}` and namespaced generated GraphQL/OData/SDK/ MCP **alongside** the current shapes; mark the old ones deprecated. Existing diff --git a/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md b/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md new file mode 100644 index 000000000..83eeb32ab --- /dev/null +++ b/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md @@ -0,0 +1,239 @@ +# ADR-0029: Kernel Object Ownership — First-Party Capabilities as Plugins That Own Their Data, and Decomposing the `platform-objects` Monolith + +**Status**: Proposed (2026-06-01) +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0003](./0003-package-as-first-class-citizen.md) (package as first-class citizen), [ADR-0019](./0019-app-as-consumer-unit.md) (app as the consumer-facing unit), [ADR-0025](./0025-plugin-package-distribution.md) (plugin package distribution + dependencies) +**Related**: [ADR-0028](./0028-metadata-naming-and-namespace-isolation.md) (metadata naming & namespace isolation) **depends on** this ADR — its D5/D6 (reserved `sys` namespace, single-owner-per-object, apps-cannot-define-kernel) assume the kernel is properly owned. This ADR is sequenced **first** and is independently valuable; ADR-0028 owns the naming model, this ADR owns kernel object *ownership*. +**Consumers**: `@objectstack/platform-objects` (decomposed), `@objectstack/plugins/plugin-auth` · `plugin-audit` · `plugin-sharing` · `plugin-approvals` · `plugin-webhooks`, `@objectstack/services/service-job` · `service-ai` · `service-settings` · `plugin-email`, `@objectstack/objectql` (`SchemaRegistry` ownership + `RESERVED_NAMESPACES`), `@objectstack/runtime` (bootstrap / load-order), `@objectstack/spec` (manifest `scope`, reserved-namespace enforcement) + +--- + +## TL;DR + +Every `sys_*` kernel object is defined in the **`platform-objects` monolith**, +even though the plugins that conceptually own them — `plugin-auth`, +`service-job`, `service-settings`, … — only declare `namespace:'sys'` in their +manifests. Ownership *declaration* is split from object *definition*: plugins are +hollowed into behavior-only shells whose data model lives elsewhere. That +contradicts the microkernel principle that a (even first-party) capability is a +plugin shipping **its own data model + behavior** as one cohesive unit. + +This ADR makes first-party capabilities own their kernel objects: + +1. **A first-party capability is a plugin that owns its `sys_*` objects + its + behavior.** `plugin-auth` owns `sys_user`/`sys_session`/`sys_organization`, + `plugin-audit` owns `sys_audit_log`, `service-job` owns `sys_job`, etc. +2. **Small core, everything else a capability plugin.** A short *core-mandatory* + list (identity/org hub + metadata store) stays always-present; the rest + (audit, jobs, email, approvals, sharing, AI, webhooks) becomes + independently-installable capability plugins. +3. **`sys` is one shared, reserved namespace with single-owner *per object*** — + not single-owner-per-namespace and not a monolith owner. The existing + `own`/`extend` model already enforces one owner per object name. +4. **The hub problem is solved by dependencies + load-order, not by + centralization.** `sys_user`/`sys_organization` are core-mandatory; capability + plugins declare a `dependency` on them and the loader sequences owners before + referencers. +5. **`platform-objects` is decomposed** — shrinks to the core-mandatory slice (or + dissolves into the capability plugins behind a thin re-export facade). + +This is **template-transparent** (apps only *reference* `sys_*`; resolution is +unchanged) and therefore the lowest-risk, independently-shippable foundation for +the larger naming refactor in ADR-0028. + +--- + +## Context + +### The problem + +The codebase scan found the kernel is a monolith with split ownership: + +| Finding | Evidence | +|:--|:--| +| **All `sys_*` objects are defined in `platform-objects`** — identity, audit, security, metadata, system domains. | `platform-objects/src/{identity,audit,security,metadata,system}/**` | +| Plugins **declare** `namespace:'sys'`, `scope:'system'` but **define no objects** — the data model lives in `platform-objects`. | `plugin-auth/src/manifest.ts:58-67`; `service-job`, `service-settings` manifests | +| `sys` is a **shared** namespace co-claimed by ~14 packages with no arbiter at the namespace level. | `objectql/src/registry.ts:346-389` (`namespaceRegistry: Map>`) | +| The `own`/`extend` ownership model is fully implemented: **one owner per object** (second `own` throws), extenders merge by `priority` (owner 100, extender 200). **No package extends a `sys_` object today.** | `objectql/src/registry.ts:406-518`; `object.zod.ts:856-897` | +| ~60 lookup fields converge on the **hub objects `sys_user` / `sys_organization`**, referenced from every domain (incl. `service-ai`'s `ai_conversations.user_id`). | scan | +| `RESERVED_NAMESPACES = {'base','system'}` — `sys` is **not** reserved. "Apps may reference but never define `sys_*`" is documented intent with **no enforcing validator**. | `registry.ts:13`; `manifest.zod.ts:66-70` | +| `scope: cloud\|system\|project` and `managedBy: platform\|config\|system\|append-only\|better-auth` already mark system data. | `manifest.zod.ts:133`; `object.zod.ts:354-385` | + +### How mainstream platforms structure the kernel + +| System | Microkernel? | Who owns kernel/standard objects | First-party features | +|:--|:--|:--|:--| +| **VS Code** | Yes — tiny core | Core owns the editor model | **Even built-in languages ship as extensions** that own their contributions | +| **Kubernetes** | Yes — small API core | Core API objects | Capabilities added via API-extensions / CRDs + controllers (each owns its types) | +| **Salesforce** | Platform core | Standard objects owned by core, **not packageable** | Clouds (Sales/Service) ship as managed first-party units; standard objects stay core | +| **ServiceNow** | Platform core | `sys_*` base tables shipped by the platform | **Plugins** (activatable feature sets) add and own their own tables; CMDB/user stay core | + +**Consensus this ADR adopts:** keep the core small; let first-party capabilities +be plugins that own their data; reserve a platform namespace for kernel objects +that packages may extend but not redefine. + +--- + +## Decision + +### D1 — A first-party capability is a plugin that owns its data *and* behavior + +The unit of a capability is a plugin that ships its `sys_*` object definitions +alongside its services/hooks/flows — not a behavior shell pointing at a shared +data monolith. Ownership *declaration* (`manifest`) and object *definition* +(`*.object.ts`) live in the same package. Concretely: + +| Capability plugin | Owns (`own`) | +|:--|:--| +| `plugin-auth` (or a base `plugin-identity`) | `sys_user`, `sys_session`, `sys_organization`, `sys_account`, `sys_team*`, `sys_member`, `sys_oauth_*`, `sys_two_factor`, `sys_api_key`, `sys_device_code`, `sys_jwks`, `sys_invitation`, `sys_department*`, `sys_user_preference` | +| `plugin-audit` | `sys_audit_log`, `sys_activity`, `sys_comment`, `sys_presence`, `sys_attachment`, `sys_notification` | +| `plugin-approvals` | `sys_approval_request`, `sys_approval_action` | +| `plugin-sharing` | `sys_role`, `sys_permission_set`, `sys_*_permission_set`, `sys_sharing_rule`, `sys_record_share`, `sys_share_link` | +| `service-job` | `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_report_schedule` | +| `plugin-email` | `sys_email`, `sys_email_template` | +| `plugin-webhooks` | `sys_webhook`, `sys_webhook_delivery` | +| `service-ai` | `ai_*` (already owns these; folded under the contract per ADR-0028) | +| core / `plugin-metadata` | `sys_metadata*`, `sys_view_definition`, `sys_setting*`, `sys_saved_report` | + +(Exact assignment of security objects — under `plugin-sharing` vs a dedicated +`plugin-rbac` — to settle in implementation.) + +### D2 — Small core; everything else is a capability plugin + +Split kernel objects into two tiers by a clear criterion: + +- **Core-mandatory** — referenced by (almost) everything and has no meaningful + "disabled" state: the **identity/org hub** (`sys_user`, `sys_organization`) and + the **metadata store** (`sys_metadata*`). Always present; owned by a + foundational base package (`plugin-identity` + core metadata) that cannot be + uninstalled. +- **Capability** — has a coherent on/off boundary: audit, jobs, email, + approvals, sharing, AI, webhooks. Independently installable/disablable; each + owns its `sys_*` objects. When disabled, its objects simply aren't registered. + +### D3 — `sys` is one shared, reserved namespace, single-owner *per object* + +The invariant is **single-owner-per-object-name** (already enforced — a second +`own` throws), **not** single-owner-per-namespace and **not** one monolith owner. +Many first-party plugins co-contribute into the one `sys` namespace; each object +name has exactly one owner. Collision safety comes from the object-level `own` +check plus the install-time identifier registry (ADR-0028 D6). Other plugins may +`extend` a `sys_*` object (add fields/indexes via `objectExtensions`, merged by +priority) — the supported way to augment kernel objects. + +### D4 — Reserve `sys`; apps reference but cannot define kernel objects + +Add `sys` to `RESERVED_NAMESPACES`. Enforce structurally in `registerObject`: a +`scope:'project'` / `type:'app'` package attempting to `own`/define an object in +a reserved kernel namespace is rejected (the `sys_`-prefix *exemption* becomes a +*prohibition* for apps). Apps may still `extend` kernel objects. This converts +the documented "reference-but-not-define" intent into a real boundary. + +### D5 — Hub + load-order, not centralization + +The two forces that historically drove the monolith are addressed without it: + +1. **Hub references** — `sys_user` / `sys_organization` are core-mandatory (D2); + every capability plugin declares an explicit `dependency` on the base identity + package rather than embedding the objects. +2. **Bootstrap order** — the loader sequences an object's owner to register + before any referencer, via the existing `dependencies` + plugin-loading + `loadOrder`. Owning a `sys_*` object is just another declared dependency edge — + which is precisely the plugin system's job. + +### D6 — Decompose `platform-objects` + +`platform-objects` shrinks to the **core-mandatory** slice (identity/org hub + +metadata + shared base field-sets/mixins) — or dissolves entirely into the +capability plugins behind a thin **re-export facade** that preserves the current +import surface during migration. Shared schema fragments (audit/system field +mixins, common lookups) move to a small `platform-objects-base` (or `spec`) +module that capability plugins import, so decomposition does not duplicate them. + +--- + +## Migration plan (template-transparent, independently shippable) + +Apps only *reference* `sys_*`; resolution is unchanged throughout, so existing +templates are unaffected. This sequence can land **before** and independently of +the ADR-0028 naming flip. + +- **K0 — Ownership model readiness.** Confirm `own`/`extend` + `dependencies` + + `loadOrder` cover cross-package ownership with the hub dependency edges; add an + install-time check that every `sys_*` object resolves to exactly one owner. + *Exit:* registry resolves the full current kernel identically with explicit + single owners; no resolution diffs. +- **K1 — Base identity + reserved namespace.** Extract the core-mandatory hub + (`sys_user`/`sys_organization`/`sys_metadata*`) into the always-present base + package; add `sys` to `RESERVED_NAMESPACES`; wire dependency edges. No object + moves owner yet beyond the hub. + *Exit:* identity/auth bootstrap green; load-order deterministic. +- **K2 — Move ownership to capability plugins (incrementally, one domain at a + time).** For each domain (audit → jobs → email → approvals → sharing → + webhooks), relocate the `*.object.ts` definitions into the owning plugin and + switch its manifest from "declare `namespace:'sys'`" to actual `own`. Keep a + `platform-objects` re-export facade so importers don't break mid-migration. + *Exit per domain:* that domain's objects resolve to the new owner; its tests + green; cross-domain lookups to the hub still resolve. +- **K3 — Boundary enforcement.** Flip the app-cannot-define-kernel check + warn→error. Classify the scattered `ai`/`mail`/`branding`/`prefs`/`feat`/… — + each object either folds into the kernel contract (owned by its capability + plugin) or becomes an ordinary prefixed package. Delete `nope`. + *Exit:* no app defines a `sys_*` object; quasi-kernel namespaces classified. +- **K4 — Remove the facade.** Once all importers reference capability plugins + directly, drop the `platform-objects` re-export facade (or reduce + `platform-objects` to the base slice). + *Exit:* `platform-objects` contains only core-mandatory + shared base, or is + gone. + +--- + +## Consequences + +**Positive** + +- First-party capabilities become true plugins (data + behavior in one unit) — + the platform "dogfoods" its own extensibility model; what ships the kernel is + the same mechanism third parties use. +- Capabilities gain a real on/off boundary (audit/jobs/email/… can be omitted), + shrinking minimal deployments and clarifying dependencies. +- Single-owner-per-object + reserved `sys` gives the kernel the same + collision-safety apps already enjoy, and lays the foundation ADR-0028 needs. +- Ownership declaration and definition are reunited; the "shell plugin" smell is + removed. + +**Negative / costs** + +- Non-trivial internal refactor of `platform-objects` and ~8 plugins; load-order + and the `sys_user`/`sys_organization` hub dependency must be gotten right or + bootstrap breaks (mitigated by K0/K1 gating + the re-export facade). +- More packages and dependency edges to maintain. +- Risk of circular dependencies if a "capability" object is over-eagerly made to + reference another capability's object; the hub must stay in the base tier and + cross-capability references should be minimized (or go through the hub). + +**Neutral / open** + +- Exact home of the security/RBAC objects (`plugin-sharing` vs `plugin-rbac`). +- Whether the base tier is a dedicated `plugin-identity` or stays inside + `platform-objects-base`. +- Whether disabled capabilities should hard-remove their tables or leave them + dormant (interacts with `managedBy` and uninstall semantics). + +--- + +## Alternatives considered + +- **Keep the monolith (`platform-objects` owns all).** Simplest, no load-order + work, but perpetuates the shell-plugin smell and the namespace-without-arbiter + fragility; rejected as the long-term shape (it is the historical artifact this + ADR addresses). +- **One owner, others `extend`.** `platform-objects` keeps `own`ership and + capability plugins only add fields via `objectExtensions`. Preserves a single + definition site but still hollows the plugins (they own behavior, not their + core data) — a half-measure; rejected in favor of true per-capability + ownership. +- **Per-domain sub-namespaces (`identity`, `audit`, …) instead of one `sys`.** + This is a *naming/reference-surface* question owned by ADR-0028 (rejected there + on industry practice + the hub cross-reference graph). Ownership distribution + (this ADR) is orthogonal and does not require sub-namespaces. From 6ac867c4f3ba398266b481228975ed60f6c2df5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 03:00:03 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs(adr):=20ADR-0029=20=E2=80=94=20add=20n?= =?UTF-8?q?avigation-contribution=20mechanism=20for=20the=20setup=20app=20?= =?UTF-8?q?(D7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decomposing platform-objects breaks the premise that lets the `setup` admin app be a static monolith (it hard-references every sys_* object; its own comment notes it was made static *because* the objects were centralized, and the runtime-assembling plugin-setup was deleted). manifest.contributes.menus exists but is consumed nowhere, and there is no app-extension analog to objectExtensions. Add D7: setup becomes a base-owned "shell + group slots"; each capability plugin contributes its nav entries via a declarative navigation contribution (the UI analog of objectExtensions), merged by group + priority, each entry gated by the existing requiresObject / requiredPermissions nav fields (which doubles as the disable mechanism). Wire it through the migration plan (K1 builds the shell + mechanism; K2 moves each domain's nav entries out as contributions) and record the schema choice as an open question. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 --- ...ship-and-platform-objects-decomposition.md | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md b/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md index 83eeb32ab..dd534dcef 100644 --- a/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md +++ b/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md @@ -58,6 +58,8 @@ The codebase scan found the kernel is a monolith with split ownership: | ~60 lookup fields converge on the **hub objects `sys_user` / `sys_organization`**, referenced from every domain (incl. `service-ai`'s `ai_conversations.user_id`). | scan | | `RESERVED_NAMESPACES = {'base','system'}` — `sys` is **not** reserved. "Apps may reference but never define `sys_*`" is documented intent with **no enforcing validator**. | `registry.ts:13`; `manifest.zod.ts:66-70` | | `scope: cloud\|system\|project` and `managedBy: platform\|config\|system\|append-only\|better-auth` already mark system data. | `manifest.zod.ts:133`; `object.zod.ts:354-385` | +| The **`setup` admin app is a static monolith** that hard-references every `sys_*` object as nav entries — and its own comment notes it was made static *because* the objects were centralized (the older `@objectstack/plugin-setup` that assembled it at runtime was deleted). | `platform-objects/src/apps/setup.app.ts` | +| `manifest.contributes.menus` exists in the schema but is **consumed nowhere** — a vestigial, unimplemented contribution point. No app-navigation merge / `appExtensions` analog to `objectExtensions` exists. | `manifest.zod.ts` (`contributes.menus`); no consumer found | ### How mainstream platforms structure the kernel @@ -150,6 +152,38 @@ import surface during migration. Shared schema fragments (audit/system field mixins, common lookups) move to a small `platform-objects-base` (or `spec`) module that capability plugins import, so decomposition does not duplicate them. +### D7 — Shared admin surfaces are "shell + slots"; capability plugins contribute navigation + +Decomposition breaks the premise that lets the `setup` app be a static monolith +(it hard-references every `sys_*` object, and was made static *because* the +objects were centralized). The fix mirrors `own`/`extend` at the UI layer: + +- The `setup` app is **owned by a base package** (revived `plugin-setup` or the + base tier) and defines only the **shell + stable group/category anchors + ("slots")** — `Overview`, `Apps`, `People & Org`, `Access Control`, + `Automation`, `Security`, `Developer`, … It does **not** enumerate capability + objects. +- Each capability plugin **contributes** its nav entries into a named slot via a + declarative **navigation contribution** — the UI-layer analog of + `objectExtensions`. This finally implements the vestigial + `manifest.contributes.menus` (or a proper `appExtensions` / + `navigationContributions` schema): `{ app: 'setup', group: 'security', items: + [...], priority }`. +- The loader **merges contributions into the owning app by group id + priority**, + exactly as object extenders merge (owner shell first, contributions by + ascending priority). +- Each entry stays **gated by the existing nav fields** `requiresObject` / + `requiredPermissions` (already in the App nav schema, e.g. + `requiresObject: 'sys_package_installation'`). This doubles as the + enable/disable mechanism: a disabled capability registers no objects, so its + gated menu entries simply don't render — no dangling links. + +This is the general extension point a marketplace needs anyway: any app +(third-party included) becomes navigation-extensible, not just `setup`. Scope +for this ADR is the `setup` app and first-party capability contributions; +generalizing app-extension to arbitrary apps is a follow-up. References inside +contributions follow ADR-0028's naming model (`sys.audit_log`, etc.). + --- ## Migration plan (template-transparent, independently shippable) @@ -163,18 +197,25 @@ the ADR-0028 naming flip. install-time check that every `sys_*` object resolves to exactly one owner. *Exit:* registry resolves the full current kernel identically with explicit single owners; no resolution diffs. -- **K1 — Base identity + reserved namespace.** Extract the core-mandatory hub - (`sys_user`/`sys_organization`/`sys_metadata*`) into the always-present base - package; add `sys` to `RESERVED_NAMESPACES`; wire dependency edges. No object - moves owner yet beyond the hub. - *Exit:* identity/auth bootstrap green; load-order deterministic. +- **K1 — Base identity + reserved namespace + setup shell.** Extract the + core-mandatory hub (`sys_user`/`sys_organization`/`sys_metadata*`) into the + always-present base package; add `sys` to `RESERVED_NAMESPACES`; wire dependency + edges. Implement the **navigation-contribution mechanism** (D7) and reduce the + `setup` app to its shell + group anchors owned by the base package; the existing + hard-coded entries become base-package contributions for now. No capability + object moves owner yet beyond the hub. + *Exit:* identity/auth bootstrap green; load-order deterministic; `setup` renders + identically, now assembled from contributions. - **K2 — Move ownership to capability plugins (incrementally, one domain at a time).** For each domain (audit → jobs → email → approvals → sharing → webhooks), relocate the `*.object.ts` definitions into the owning plugin and - switch its manifest from "declare `namespace:'sys'`" to actual `own`. Keep a - `platform-objects` re-export facade so importers don't break mid-migration. - *Exit per domain:* that domain's objects resolve to the new owner; its tests - green; cross-domain lookups to the hub still resolve. + switch its manifest from "declare `namespace:'sys'`" to actual `own`, and move + that domain's `setup` nav entries out of the base shell into the plugin as + navigation contributions (D7). Keep a `platform-objects` re-export facade so + importers don't break mid-migration. + *Exit per domain:* that domain's objects resolve to the new owner; its setup + menu entries render via its own contribution; its tests green; cross-domain + lookups to the hub still resolve. - **K3 — Boundary enforcement.** Flip the app-cannot-define-kernel check warn→error. Classify the scattered `ai`/`mail`/`branding`/`prefs`/`feat`/… — each object either folds into the kernel contract (owned by its capability @@ -219,6 +260,10 @@ the ADR-0028 naming flip. `platform-objects-base`. - Whether disabled capabilities should hard-remove their tables or leave them dormant (interacts with `managedBy` and uninstall semantics). +- The navigation-contribution schema (D7): revive/extend the vestigial + `manifest.contributes.menus` vs add a first-class `appExtensions` / + `navigationContributions` collection — and how far to generalize app-extension + beyond the `setup` app (arbitrary third-party apps) in this ADR vs a follow-up. --- From 75d3afb056d02e9518efdde1c0f206362434b504 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 08:02:15 +0000 Subject: [PATCH 6/6] fix(objectql): apply D7 nav contributions in the protocol serving path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Setup app is a shell of empty group anchors (ADR-0029 D7); menu entries are injected as navigation contributions and merged lazily on read. `SchemaRegistry.getApp` / `getAllApps` did the merge, but the REST app endpoints read through `protocol.getMetaItems` / `getMetaItem`, which returned the raw shell — so every Setup menu group rendered empty. Apply the contribution merge in both protocol read paths (list + single) for the `app`/`apps` types, mirroring `getApp`/`getAllApps`. The stored app is never mutated (structuredClone), so reads stay idempotent. `SchemaRegistry.applyNavContributions` is promoted from private to public so the protocol can reach the same merge logic. Adds regression tests covering both `getMetaItems({type:'app'})` and `getMetaItem({type:'app'})`. --- packages/objectql/src/protocol-meta.test.ts | 53 +++++++++++++++++++++ packages/objectql/src/protocol.ts | 18 +++++++ packages/objectql/src/registry.ts | 7 ++- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 55abd2862..48f983976 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -791,6 +791,59 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); }); + // ═══════════════════════════════════════════════════════════════ + // ADR-0029 D7 — navigation contributions reach the serving path + // + // Regression: the setup app is a shell of empty group anchors; menu + // entries are injected as navigation contributions and merged lazily on + // read. `registry.getApp` / `getAllApps` did the merge, but the REST app + // endpoints read through `protocol.getMetaItems` / `getMetaItem`, which + // returned the raw shell — leaving every Setup menu group empty. + // ═══════════════════════════════════════════════════════════════ + + describe('app navigation contributions (ADR-0029 D7)', () => { + const shellApp = { + name: 'setup', + label: 'Setup', + navigation: [ + { id: 'group_diagnostics', type: 'group', label: 'Diagnostics', children: [] }, + ], + }; + + const contribution = { + app: 'setup', + group: 'group_diagnostics', + priority: 100, + items: [{ id: 'nav_audit_logs', type: 'object', label: 'Audit Logs', objectName: 'sys_audit_log' }], + }; + + it('getMetaItems({type:"app"}) merges contributions into the served app', async () => { + registry.registerItem('app', shellApp, 'name'); + registry.registerAppNavContribution(contribution, 'platform-objects'); + + const result = await protocol.getMetaItems({ type: 'app' }); + + const setup = (result.items as any[]).find((a) => a.name === 'setup'); + expect(setup).toBeDefined(); + const group = setup.navigation.find((g: any) => g.id === 'group_diagnostics'); + expect(group.children).toHaveLength(1); + expect(group.children[0].id).toBe('nav_audit_logs'); + // The stored shell is never mutated — repeated reads stay idempotent. + expect((registry.getItem('app', 'setup') as any).navigation[0].children).toHaveLength(0); + }); + + it('getMetaItem({type:"app"}) merges contributions for a single-app fetch', async () => { + registry.registerItem('app', shellApp, 'name'); + registry.registerAppNavContribution(contribution, 'platform-objects'); + + const result = await protocol.getMetaItem({ type: 'app', name: 'setup' }); + + const group = (result.item as any).navigation.find((g: any) => g.id === 'group_diagnostics'); + expect(group.children).toHaveLength(1); + expect(group.children[0].id).toBe('nav_audit_logs'); + }); + }); + // ═══════════════════════════════════════════════════════════════ // loadMetaFromDb — startup hydration // ═══════════════════════════════════════════════════════════════ diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index a96d82a81..7f85707d1 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -1281,6 +1281,16 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { items = (items as any[]).filter((it) => !isAggregatedViewContainer(it)); } + // Merge registered navigation contributions into each served app + // (ADR-0029 D7). The setup app is a shell of empty group anchors; + // platform-objects and capability plugins inject their menu entries as + // contributions, merged lazily on read. REST app endpoints read through + // this path (not registry.getAllApps), so the merge must happen here too + // or every contributed group renders empty. + if (request.type === 'app' || request.type === 'apps') { + items = (items as any[]).map((app) => this.engine.registry.applyNavContributions(app)); + } + return { type: request.type, items: decorateMetadataItems( @@ -1417,6 +1427,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } } + // Merge registered navigation contributions into a served app + // (ADR-0029 D7) — parity with the getMetaItems list path so a + // single-app fetch (GET /meta/app/) also sees the contributed + // menu entries, not just the empty group-anchor shell. + if ((request.type === 'app' || request.type === 'apps') && item) { + item = this.engine.registry.applyNavContributions(item); + } + // ADR-0010 §3.3 — artifact-level protection (lock/packageId) always // wins over any overlay row. The metadata service may return a // persisted overlay copy that pre-dates the artifact's `_lock` diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 7c0db4cf0..ab85435c7 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1012,8 +1012,13 @@ export class SchemaRegistry { * Return a copy of `app` with all registered navigation contributions * merged into its `navigation` tree. The stored app is never mutated, so * repeated reads stay idempotent. + * + * Public so the protocol serving path (`getMetaItems` / `getMetaItem` for + * `app`) can merge contributions the same way `getApp` / `getAllApps` do — + * the REST app endpoints read through the protocol, not these helpers, so + * the merge must be reachable from there too (ADR-0029 D7). */ - private applyNavContributions(app: any): any { + applyNavContributions(app: any): any { const contributions = this.appNavContributions.get(app?.name); if (!contributions || contributions.length === 0) return app;