Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/adr-0029-k0-webhooks-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/objectql": minor
"@objectstack/platform-objects": minor
"@objectstack/plugin-webhooks": minor
---

ADR-0029 K0 + K2.a — single-owner invariant and webhooks ownership pilot.

**K0 (`@objectstack/objectql`)** — add `SchemaRegistry.assertSingleOwnerPerObject()`,
the install-time backstop for the kernel-decomposition invariant: every
registered object must resolve to exactly one `own` contributor. A second
cross-package owner is already rejected at registration time; this additionally
catches "extend with no owner" (which would otherwise resolve to nothing). Call
after kernel bootstrap completes.

**K2.a (`@objectstack/plugin-webhooks` ← `@objectstack/platform-objects`)** — move
the `sys_webhook` object definition out of the `platform-objects` monolith into
`@objectstack/plugin-webhooks`, where it joins its sibling `sys_webhook_delivery`
so the plugin owns both its data model and behavior as one unit. `sys_webhook` is
no longer exported from `@objectstack/platform-objects` (or its `/integration`
subpath, now an empty barrel); import it from `@objectstack/plugin-webhooks/schema`
instead. Runtime behavior is unchanged — the webhook plugin already registered
`sys_webhook` at runtime; only the definition's home moved. Setup-app navigation
(which references `sys_webhook` by name) and existing i18n bundles (object-name
keyed) continue to work. Per ADR-0029 D8, migrating the object's i18n extraction
into the plugin is a tracked follow-up before the next translation regeneration.
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,39 @@ 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.).

### D8 — An object's i18n resources migrate with its ownership

A kernel object is more than its schema: it has **localized labels, field help,
and list-view names**. Today these live in `platform-objects` as generated
bundles (`src/apps/translations/*.objects.generated.ts`, produced by
`os i18n extract` against `scripts/i18n-extract.config.ts`, loaded at runtime by
the `platform-objects` plugin). The generated entries are keyed by **object
name** (`sys_webhook: {...}`) and loaded globally, so they keep working at
runtime regardless of which package owns the object — but their **source of
truth** stays wrongly attached to the monolith.

Therefore object migration must carry the i18n resources, not just the schema:

- The owning plugin becomes the **source of truth** for its objects'
translations — it owns the extract config entry and ships the generated
bundle(s), and contributes them at runtime (e.g. via `i18n.loadTranslations`
or `manifest.translations`), exactly as `platform-objects` does today.
- When an object leaves `platform-objects`, it is removed from
`scripts/i18n-extract.config.ts`; **regenerating before the plugin owns its
extraction would silently drop locales** — so the plugin-side i18n extraction
must land in the same step (or the object stays in the extract set
transitionally, explicitly tracked).
- A plugin that currently has **no i18n infrastructure** (e.g. `plugin-webhooks`,
whose `sys_webhook_delivery` ships inline labels only) must gain one as part of
taking ownership of a localized object — this is real, recurring work in every
K2 domain move and must be budgeted, not assumed free.

Pilot note: the first pilot (webhooks) moves the schema and removes the object
from the monolith extract config, but **defers building plugin-side i18n
extraction** — the existing generated entries remain valid at runtime
(object-name-keyed), and the plugin-owned i18n bundle is the explicit next sub-task
before any regeneration.

---

## Migration plan (template-transparent, independently shippable)
Expand Down Expand Up @@ -211,10 +244,14 @@ the ADR-0028 naming flip.
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.
navigation contributions (D7). **Migrate the object's i18n resources with it**
(see D8). Keep a `platform-objects` re-export facade so importers don't break
mid-migration (where the dependency direction forbids a facade — e.g. a leaf
plugin `platform-objects` already depends on — do a clean move instead and
update the importers).
*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
menu entries render via its own contribution; its translations load from the
owning plugin (no localization regression); 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`/… —
Expand Down
86 changes: 86 additions & 0 deletions packages/objectql/src/registry-single-owner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { SchemaRegistry } from './registry';

/**
* ADR-0029 K0 — single-owner-per-object invariant.
*
* The kernel `sys` namespace is shared across many first-party plugins
* (plugin-auth, plugin-audit, plugin-webhooks, ...), but every object name
* must resolve to exactly one owner. `registerObject` rejects a second
* cross-package owner eagerly; `assertSingleOwnerPerObject` is the
* install-time backstop that additionally catches "extend with no owner".
*/
describe('SchemaRegistry single-owner-per-object (ADR-0029 K0)', () => {
let registry: SchemaRegistry;
beforeEach(() => {
registry = new SchemaRegistry({ multiTenant: false });
});

it('passes when each object has exactly one owner across packages sharing `sys`', () => {
registry.registerObject(
{ name: 'sys_webhook', fields: {} } as any,
'com.objectstack.plugin-webhook-outbox.schema',
'sys',
'own',
);
registry.registerObject(
{ name: 'sys_user', fields: {} } as any,
'com.objectstack.plugin-auth',
'sys',
'own',
);
expect(() => registry.assertSingleOwnerPerObject()).not.toThrow();
});

it('passes when an owned object also has extenders from other packages', () => {
registry.registerObject(
{ name: 'sys_user', fields: { a: { type: 'text' } } } as any,
'com.objectstack.plugin-auth',
'sys',
'own',
);
registry.registerObject(
{ name: 'sys_user', fields: { b: { type: 'text' } } } as any,
'com.acme.app',
undefined,
'extend',
200,
);
expect(() => registry.assertSingleOwnerPerObject()).not.toThrow();
});

it('throws when an object has only extend contributions and no owner', () => {
// registerObject permits extending a not-yet-owned object; the backstop
// must surface it rather than letting it resolve to nothing.
registry.registerObject(
{ name: 'sys_audit_log', fields: { note: { type: 'text' } } } as any,
'com.acme.app',
undefined,
'extend',
200,
);
expect(() => registry.assertSingleOwnerPerObject()).toThrowError(/no owner/);
expect(() => registry.assertSingleOwnerPerObject()).toThrowError(/sys_audit_log/);
});

it('rejects a second cross-package owner at registration time', () => {
registry.registerObject(
{ name: 'sys_job', fields: {} } as any,
'com.objectstack.service-job',
'sys',
'own',
);
expect(() =>
registry.registerObject(
{ name: 'sys_job', fields: {} } as any,
'com.evil.app',
'sys',
'own',
),
).toThrowError(/already owned/);
// The registry remains single-owner after the rejected attempt.
expect(() => registry.assertSingleOwnerPerObject()).not.toThrow();
});
});
43 changes: 43 additions & 0 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,49 @@ export class SchemaRegistry {
return contributors?.find(c => c.ownership === 'own');
}

/**
* ADR-0029 K0 — assert every registered object resolves to exactly one
* owner.
*
* A second `own` from a different package is already rejected eagerly in
* {@link registerObject} (it throws). This is the install-time backstop
* called once all packages are registered (kernel bootstrap complete),
* and it additionally catches the case `registerObject` cannot: an object
* that has only `extend` contributions and **no owner** — which would
* otherwise resolve to nothing. Surfacing it here turns a silent
* "extend a non-existent object" into a clear bootstrap error.
*
* This is the invariant the kernel-decomposition (ADR-0029) relies on:
* the `sys` namespace is shared across many first-party plugins, but each
* object name has exactly one owner.
*
* @throws Error listing every object whose owner count is not exactly 1.
*/
assertSingleOwnerPerObject(): void {
const violations: string[] = [];
for (const [fqn, contributors] of this.objectContributors.entries()) {
const owners = contributors.filter(c => c.ownership === 'own');
if (owners.length === 0) {
const extenders = contributors.map(c => c.packageId).join(', ') || '(none)';
violations.push(
`Object "${fqn}" has no owner — only extend contributions from [${extenders}]. ` +
`Exactly one package must register it with ownership 'own'.`
);
} else if (owners.length > 1) {
const names = owners.map(c => c.packageId).join(', ');
violations.push(
`Object "${fqn}" has ${owners.length} owners [${names}] — exactly one is allowed.`
);
}
}
if (violations.length > 0) {
throw new Error(
`[Registry] single-owner-per-object check failed (ADR-0029):\n ` +
violations.join('\n ')
);
}
}

/**
* Unregister all objects contributed by a package.
*
Expand Down
7 changes: 4 additions & 3 deletions packages/platform-objects/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ import {
} from '../src/audit/index.js';

// ── Integration ───────────────────────────────────────────────────────────
import { SysWebhook } from '../src/integration/index.js';
// sys_webhook moved to @objectstack/plugin-webhooks per ADR-0029 (K2.a).
// Its i18n extraction must move to that plugin before the next regeneration
// (ADR-0029 D8); existing generated bundles keep working until then.

// ── Metadata ──────────────────────────────────────────────────────────────
import {
Expand Down Expand Up @@ -171,8 +173,7 @@ export default defineStack({
SysJobRun,
SysJobQueue,

// Integration
SysWebhook,
// Integration: sys_webhook moved to @objectstack/plugin-webhooks (ADR-0029 D8).

// Metadata
SysMetadataObject,
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-objects/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* @objectstack/platform-objects/identity — user, session, org, team, api-key, ...
* @objectstack/platform-objects/security — role, permission-set
* @objectstack/platform-objects/audit — audit-log, presence
* @objectstack/platform-objects/integration — webhook (outbound HTTP integrations)
* @objectstack/platform-objects/integration — (empty; sys_webhook moved to @objectstack/plugin-webhooks per ADR-0029)
* @objectstack/platform-objects/metadata — sys_metadata, sys_metadata_history
* @objectstack/platform-objects/apps — built-in platform Apps (Setup, ...)
*
Expand Down
17 changes: 10 additions & 7 deletions packages/platform-objects/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
/**
* platform-objects/integration — External Integration Platform Objects
*
* Outbound HTTP webhooks (and, in the future, inbound receivers) live
* here because they apply to every kernel — standalone, single-tenant,
* and multi-tenant cloud projects alike. Any project can configure a
* webhook to notify an external system on record events; the runtime
* is provided by @objectstack/service-automation's built-in `http_request`
* node (seeded by AutomationServicePlugin).
* **Empty since ADR-0029 (K2.a).** `sys_webhook` moved to its owner,
* `@objectstack/plugin-webhooks` (alongside `sys_webhook_delivery`), so the
* plugin ships its data model and behavior as one unit. Import the schema
* from `@objectstack/plugin-webhooks/schema` instead.
*
* The subpath (`@objectstack/plugin-webhooks/integration`) is retained as an
* empty barrel to avoid churning the package `exports` map / tsup entries
* during the incremental decomposition; it can be removed once the
* decomposition completes (ADR-0029 K4).
*/

export { SysWebhook } from './sys-webhook.object.js';
export {};
3 changes: 1 addition & 2 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
defaultPermissionSets,
} from './security/index.js';
import { SysAuditLog, SysPresence } from './audit/index.js';
import { SysWebhook } from './integration/index.js';
// sys_webhook moved to @objectstack/plugin-webhooks per ADR-0029 (K2.a).
import {
SysMetadata,
SysMetadataHistoryObject,
Expand Down Expand Up @@ -52,7 +52,6 @@ const systemObjects = [
['SysRolePermissionSet', SysRolePermissionSet, 'sys_role_permission_set'],
['SysAuditLog', SysAuditLog, 'sys_audit_log'],
['SysPresence', SysPresence, 'sys_presence'],
['SysWebhook', SysWebhook, 'sys_webhook'],
['SysMetadata', SysMetadata, 'sys_metadata'],
['SysMetadataHistoryObject', SysMetadataHistoryObject, 'sys_metadata_history'],
['SysSetting', SysSetting, 'sys_setting'],
Expand Down
10 changes: 7 additions & 3 deletions packages/plugins/plugin-webhooks/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
* Public schema subpath: `@objectstack/plugin-webhooks/schema`.
*
* Thin re-export barrel kept stable across refactors. The actual object
* definition lives in `sys-webhook-delivery.object.ts` (matching the
* `*.object.ts` convention used everywhere else in the monorepo for
* `sys_*` schemas).
* definitions live in `sys-webhook.object.ts` and
* `sys-webhook-delivery.object.ts` (matching the `*.object.ts` convention
* used everywhere else in the monorepo for `sys_*` schemas).
*
* `sys_webhook` moved here from `@objectstack/platform-objects` per
* ADR-0029 (K2.a) so this plugin owns both of its objects.
*
* Note: callers that just need the runtime should import from the
* package root (`@objectstack/plugin-webhooks`), which auto-registers
Expand All @@ -16,6 +19,7 @@
* different runtime).
*/

export { SysWebhook } from './sys-webhook.object.js';
export {
SysWebhookDelivery,
SYS_WEBHOOK_DELIVERY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
* targeted object, and dispatches outbound HTTP calls when matching
* record events fire.
*
* Ownership (ADR-0029 K2.a): this object is **owned by
* `@objectstack/plugin-webhooks`** — the plugin that consumes these rows —
* alongside its sibling `sys_webhook_delivery`. It used to live in the
* `@objectstack/platform-objects` monolith and be imported here; the
* definition now lives with its owner so the plugin ships both data and
* behavior as one unit.
*
* Platform-wide on purpose: every project (standalone, single-tenant,
* cloud) can integrate with external systems (Slack, Stripe, internal
* services) the same way. The control-plane-only `sys_environment*`
* objects live in @objectstack/service-tenant; webhooks are
* orthogonal and ship with every kernel.
* services) the same way.
*
* @namespace sys
*/
Expand Down
14 changes: 7 additions & 7 deletions packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
IDataEngine,
IRealtimeService,
} from '@objectstack/spec/contracts';
import { SysWebhook } from '@objectstack/platform-objects/integration';
import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';
import { WebhookDispatcher, type DispatcherOptions } from './dispatcher.js';
import { MemoryWebhookOutbox } from './memory-outbox.js';
Expand All @@ -17,6 +16,7 @@ import {
type DeliveryRetentionOptions,
} from './retention.js';
import { SqlWebhookOutbox } from './sql-outbox.js';
import { SysWebhook } from './sys-webhook.object.js';
import { SysWebhookDelivery } from './sys-webhook-delivery.object.js';

export interface WebhookOutboxPluginOptions
Expand Down Expand Up @@ -117,12 +117,12 @@ export class WebhookOutboxPlugin implements Plugin {
);
}

// Register the schemas this plugin owns at runtime. `sys_webhook`
// (config) lives in @objectstack/platform-objects but no other
// plugin claims it — the webhook plugin is the natural owner
// since it's the consumer of those rows. `sys_webhook_delivery`
// (telemetry) is plugin-private. Registering them here means a
// stack just needs `plugins: [new WebhookOutboxPlugin(...)]`
// Register the schemas this plugin owns at runtime (ADR-0029 K2.a).
// Both `sys_webhook` (config) and `sys_webhook_delivery` (telemetry)
// are now defined and owned here — the webhook plugin ships its data
// model and behavior as one unit instead of importing `sys_webhook`
// from the @objectstack/platform-objects monolith. Registering them
// here means a stack just needs `plugins: [new WebhookOutboxPlugin(...)]`
// and both objects auto-appear in REST/Studio/Setup nav.
const manifest = ctx.getService<{ register(m: any): void }>('manifest');
if (manifest && typeof manifest.register === 'function') {
Expand Down