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..db0b3410e --- /dev/null +++ b/docs/adr/0028-metadata-naming-and-namespace-isolation.md @@ -0,0 +1,399 @@ +# 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 +**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), [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`) + +--- + +## 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* (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`), + 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, 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 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 + 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. + +--- + +## 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 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 + 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". 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..dd534dcef --- /dev/null +++ b/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md @@ -0,0 +1,284 @@ +# 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` | +| 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 + +| 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. + +### 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) + +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 + 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`, 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 + 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). +- 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. + +--- + +## 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.