From 6d8a3026d96fbea7123b6638de5ae7c7fa401500 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 03:22:10 +0000 Subject: [PATCH] feat(objectql,webhooks): ADR-0029 K0 single-owner check + K2.a webhooks ownership pilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K0 (@objectstack/objectql): - Add SchemaRegistry.assertSingleOwnerPerObject() — install-time backstop for the kernel-decomposition invariant (exactly one owner per object; also catches extend-with-no-owner). Unit-tested in registry-single-owner.test.ts. K2.a (webhooks pilot, @objectstack/plugin-webhooks <- @objectstack/platform-objects): - Move the sys_webhook object definition into plugin-webhooks alongside its sibling sys_webhook_delivery, so the plugin owns both data + behavior. - platform-objects no longer defines/exports sys_webhook; /integration is now an empty barrel (retained to avoid churning exports/tsup during decomposition). - Import from @objectstack/plugin-webhooks/schema instead. - Runtime unchanged (the plugin already registered sys_webhook); setup nav (name-keyed) and existing i18n bundles still work. Plugin-side i18n extraction is a tracked follow-up (ADR-0029 D8). Records the i18n-resource-migration requirement in ADR-0029 (D8 + K2 template). Tests: objectql 55, platform-objects 75, plugin-webhooks 45 — all green; turbo build (incl. dep graph + DTS type-check) green. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 --- .changeset/adr-0029-k0-webhooks-ownership.md | 26 ++++++ ...ship-and-platform-objects-decomposition.md | 43 +++++++++- .../src/registry-single-owner.test.ts | 86 +++++++++++++++++++ packages/objectql/src/registry.ts | 43 ++++++++++ .../scripts/i18n-extract.config.ts | 7 +- packages/platform-objects/src/index.ts | 2 +- .../platform-objects/src/integration/index.ts | 17 ++-- .../src/platform-objects.test.ts | 3 +- .../plugins/plugin-webhooks/src/schema.ts | 10 ++- .../src}/sys-webhook.object.ts | 11 ++- .../src/webhook-outbox-plugin.ts | 14 +-- 11 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 .changeset/adr-0029-k0-webhooks-ownership.md create mode 100644 packages/objectql/src/registry-single-owner.test.ts rename packages/{platform-objects/src/integration => plugins/plugin-webhooks/src}/sys-webhook.object.ts (93%) diff --git a/.changeset/adr-0029-k0-webhooks-ownership.md b/.changeset/adr-0029-k0-webhooks-ownership.md new file mode 100644 index 000000000..d45a6316e --- /dev/null +++ b/.changeset/adr-0029-k0-webhooks-ownership.md @@ -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. 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 dd534dcef..3a7cd8e63 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 @@ -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) @@ -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`/… — diff --git a/packages/objectql/src/registry-single-owner.test.ts b/packages/objectql/src/registry-single-owner.test.ts new file mode 100644 index 000000000..b939d4b5f --- /dev/null +++ b/packages/objectql/src/registry-single-owner.test.ts @@ -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(); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index f4b29b17a..98da27715 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -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. * diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 6ee444f3b..3bc5b0465 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -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 { @@ -171,8 +173,7 @@ export default defineStack({ SysJobRun, SysJobQueue, - // Integration - SysWebhook, + // Integration: sys_webhook moved to @objectstack/plugin-webhooks (ADR-0029 D8). // Metadata SysMetadataObject, diff --git a/packages/platform-objects/src/index.ts b/packages/platform-objects/src/index.ts index 175d468e2..b91f6b982 100644 --- a/packages/platform-objects/src/index.ts +++ b/packages/platform-objects/src/index.ts @@ -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, ...) * diff --git a/packages/platform-objects/src/integration/index.ts b/packages/platform-objects/src/integration/index.ts index 6235f5993..de3b69092 100644 --- a/packages/platform-objects/src/integration/index.ts +++ b/packages/platform-objects/src/integration/index.ts @@ -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 {}; diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 6a362cfb8..be5339af0 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -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, @@ -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'], diff --git a/packages/plugins/plugin-webhooks/src/schema.ts b/packages/plugins/plugin-webhooks/src/schema.ts index 37923f329..b817719ad 100644 --- a/packages/plugins/plugin-webhooks/src/schema.ts +++ b/packages/plugins/plugin-webhooks/src/schema.ts @@ -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 @@ -16,6 +19,7 @@ * different runtime). */ +export { SysWebhook } from './sys-webhook.object.js'; export { SysWebhookDelivery, SYS_WEBHOOK_DELIVERY, diff --git a/packages/platform-objects/src/integration/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts similarity index 93% rename from packages/platform-objects/src/integration/sys-webhook.object.ts rename to packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index 39e747ade..7a7cb340d 100644 --- a/packages/platform-objects/src/integration/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -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 */ diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index afff999d4..0c24a9022 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -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'; @@ -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 @@ -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') {