Skip to content

Commit eea3f1b

Browse files
authored
feat(objectql,webhooks): ADR-0029 K0 single-owner check + K2.a webhooks ownership pilot (#1433)
ADR-0029 K0 single-owner-per-object backstop in SchemaRegistry, plus the K2.a webhooks ownership pilot: move sys_webhook into @objectstack/plugin-webhooks (joining sys_webhook_delivery) so the plugin owns its data + behavior. Runtime unchanged; setup nav and i18n bundles keep working; i18n extraction migration tracked as follow-up (ADR-0029 D8). objectql 55 / platform-objects 75 / plugin-webhooks 45 tests green.
1 parent eec81be commit eea3f1b

11 files changed

Lines changed: 233 additions & 29 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/plugin-webhooks": minor
5+
---
6+
7+
ADR-0029 K0 + K2.a — single-owner invariant and webhooks ownership pilot.
8+
9+
**K0 (`@objectstack/objectql`)** — add `SchemaRegistry.assertSingleOwnerPerObject()`,
10+
the install-time backstop for the kernel-decomposition invariant: every
11+
registered object must resolve to exactly one `own` contributor. A second
12+
cross-package owner is already rejected at registration time; this additionally
13+
catches "extend with no owner" (which would otherwise resolve to nothing). Call
14+
after kernel bootstrap completes.
15+
16+
**K2.a (`@objectstack/plugin-webhooks``@objectstack/platform-objects`)** — move
17+
the `sys_webhook` object definition out of the `platform-objects` monolith into
18+
`@objectstack/plugin-webhooks`, where it joins its sibling `sys_webhook_delivery`
19+
so the plugin owns both its data model and behavior as one unit. `sys_webhook` is
20+
no longer exported from `@objectstack/platform-objects` (or its `/integration`
21+
subpath, now an empty barrel); import it from `@objectstack/plugin-webhooks/schema`
22+
instead. Runtime behavior is unchanged — the webhook plugin already registered
23+
`sys_webhook` at runtime; only the definition's home moved. Setup-app navigation
24+
(which references `sys_webhook` by name) and existing i18n bundles (object-name
25+
keyed) continue to work. Per ADR-0029 D8, migrating the object's i18n extraction
26+
into the plugin is a tracked follow-up before the next translation regeneration.

docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,39 @@ for this ADR is the `setup` app and first-party capability contributions;
184184
generalizing app-extension to arbitrary apps is a follow-up. References inside
185185
contributions follow ADR-0028's naming model (`sys.audit_log`, etc.).
186186

187+
### D8 — An object's i18n resources migrate with its ownership
188+
189+
A kernel object is more than its schema: it has **localized labels, field help,
190+
and list-view names**. Today these live in `platform-objects` as generated
191+
bundles (`src/apps/translations/*.objects.generated.ts`, produced by
192+
`os i18n extract` against `scripts/i18n-extract.config.ts`, loaded at runtime by
193+
the `platform-objects` plugin). The generated entries are keyed by **object
194+
name** (`sys_webhook: {...}`) and loaded globally, so they keep working at
195+
runtime regardless of which package owns the object — but their **source of
196+
truth** stays wrongly attached to the monolith.
197+
198+
Therefore object migration must carry the i18n resources, not just the schema:
199+
200+
- The owning plugin becomes the **source of truth** for its objects'
201+
translations — it owns the extract config entry and ships the generated
202+
bundle(s), and contributes them at runtime (e.g. via `i18n.loadTranslations`
203+
or `manifest.translations`), exactly as `platform-objects` does today.
204+
- When an object leaves `platform-objects`, it is removed from
205+
`scripts/i18n-extract.config.ts`; **regenerating before the plugin owns its
206+
extraction would silently drop locales** — so the plugin-side i18n extraction
207+
must land in the same step (or the object stays in the extract set
208+
transitionally, explicitly tracked).
209+
- A plugin that currently has **no i18n infrastructure** (e.g. `plugin-webhooks`,
210+
whose `sys_webhook_delivery` ships inline labels only) must gain one as part of
211+
taking ownership of a localized object — this is real, recurring work in every
212+
K2 domain move and must be budgeted, not assumed free.
213+
214+
Pilot note: the first pilot (webhooks) moves the schema and removes the object
215+
from the monolith extract config, but **defers building plugin-side i18n
216+
extraction** — the existing generated entries remain valid at runtime
217+
(object-name-keyed), and the plugin-owned i18n bundle is the explicit next sub-task
218+
before any regeneration.
219+
187220
---
188221

189222
## Migration plan (template-transparent, independently shippable)
@@ -211,10 +244,14 @@ the ADR-0028 naming flip.
211244
webhooks), relocate the `*.object.ts` definitions into the owning plugin and
212245
switch its manifest from "declare `namespace:'sys'`" to actual `own`, and move
213246
that domain's `setup` nav entries out of the base shell into the plugin as
214-
navigation contributions (D7). Keep a `platform-objects` re-export facade so
215-
importers don't break mid-migration.
247+
navigation contributions (D7). **Migrate the object's i18n resources with it**
248+
(see D8). Keep a `platform-objects` re-export facade so importers don't break
249+
mid-migration (where the dependency direction forbids a facade — e.g. a leaf
250+
plugin `platform-objects` already depends on — do a clean move instead and
251+
update the importers).
216252
*Exit per domain:* that domain's objects resolve to the new owner; its setup
217-
menu entries render via its own contribution; its tests green; cross-domain
253+
menu entries render via its own contribution; its translations load from the
254+
owning plugin (no localization regression); its tests green; cross-domain
218255
lookups to the hub still resolve.
219256
- **K3 — Boundary enforcement.** Flip the app-cannot-define-kernel check
220257
warn→error. Classify the scattered `ai`/`mail`/`branding`/`prefs`/`feat`/… —
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { SchemaRegistry } from './registry';
5+
6+
/**
7+
* ADR-0029 K0 — single-owner-per-object invariant.
8+
*
9+
* The kernel `sys` namespace is shared across many first-party plugins
10+
* (plugin-auth, plugin-audit, plugin-webhooks, ...), but every object name
11+
* must resolve to exactly one owner. `registerObject` rejects a second
12+
* cross-package owner eagerly; `assertSingleOwnerPerObject` is the
13+
* install-time backstop that additionally catches "extend with no owner".
14+
*/
15+
describe('SchemaRegistry single-owner-per-object (ADR-0029 K0)', () => {
16+
let registry: SchemaRegistry;
17+
beforeEach(() => {
18+
registry = new SchemaRegistry({ multiTenant: false });
19+
});
20+
21+
it('passes when each object has exactly one owner across packages sharing `sys`', () => {
22+
registry.registerObject(
23+
{ name: 'sys_webhook', fields: {} } as any,
24+
'com.objectstack.plugin-webhook-outbox.schema',
25+
'sys',
26+
'own',
27+
);
28+
registry.registerObject(
29+
{ name: 'sys_user', fields: {} } as any,
30+
'com.objectstack.plugin-auth',
31+
'sys',
32+
'own',
33+
);
34+
expect(() => registry.assertSingleOwnerPerObject()).not.toThrow();
35+
});
36+
37+
it('passes when an owned object also has extenders from other packages', () => {
38+
registry.registerObject(
39+
{ name: 'sys_user', fields: { a: { type: 'text' } } } as any,
40+
'com.objectstack.plugin-auth',
41+
'sys',
42+
'own',
43+
);
44+
registry.registerObject(
45+
{ name: 'sys_user', fields: { b: { type: 'text' } } } as any,
46+
'com.acme.app',
47+
undefined,
48+
'extend',
49+
200,
50+
);
51+
expect(() => registry.assertSingleOwnerPerObject()).not.toThrow();
52+
});
53+
54+
it('throws when an object has only extend contributions and no owner', () => {
55+
// registerObject permits extending a not-yet-owned object; the backstop
56+
// must surface it rather than letting it resolve to nothing.
57+
registry.registerObject(
58+
{ name: 'sys_audit_log', fields: { note: { type: 'text' } } } as any,
59+
'com.acme.app',
60+
undefined,
61+
'extend',
62+
200,
63+
);
64+
expect(() => registry.assertSingleOwnerPerObject()).toThrowError(/no owner/);
65+
expect(() => registry.assertSingleOwnerPerObject()).toThrowError(/sys_audit_log/);
66+
});
67+
68+
it('rejects a second cross-package owner at registration time', () => {
69+
registry.registerObject(
70+
{ name: 'sys_job', fields: {} } as any,
71+
'com.objectstack.service-job',
72+
'sys',
73+
'own',
74+
);
75+
expect(() =>
76+
registry.registerObject(
77+
{ name: 'sys_job', fields: {} } as any,
78+
'com.evil.app',
79+
'sys',
80+
'own',
81+
),
82+
).toThrowError(/already owned/);
83+
// The registry remains single-owner after the rejected attempt.
84+
expect(() => registry.assertSingleOwnerPerObject()).not.toThrow();
85+
});
86+
});

packages/objectql/src/registry.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,49 @@ export class SchemaRegistry {
595595
return contributors?.find(c => c.ownership === 'own');
596596
}
597597

598+
/**
599+
* ADR-0029 K0 — assert every registered object resolves to exactly one
600+
* owner.
601+
*
602+
* A second `own` from a different package is already rejected eagerly in
603+
* {@link registerObject} (it throws). This is the install-time backstop
604+
* called once all packages are registered (kernel bootstrap complete),
605+
* and it additionally catches the case `registerObject` cannot: an object
606+
* that has only `extend` contributions and **no owner** — which would
607+
* otherwise resolve to nothing. Surfacing it here turns a silent
608+
* "extend a non-existent object" into a clear bootstrap error.
609+
*
610+
* This is the invariant the kernel-decomposition (ADR-0029) relies on:
611+
* the `sys` namespace is shared across many first-party plugins, but each
612+
* object name has exactly one owner.
613+
*
614+
* @throws Error listing every object whose owner count is not exactly 1.
615+
*/
616+
assertSingleOwnerPerObject(): void {
617+
const violations: string[] = [];
618+
for (const [fqn, contributors] of this.objectContributors.entries()) {
619+
const owners = contributors.filter(c => c.ownership === 'own');
620+
if (owners.length === 0) {
621+
const extenders = contributors.map(c => c.packageId).join(', ') || '(none)';
622+
violations.push(
623+
`Object "${fqn}" has no owner — only extend contributions from [${extenders}]. ` +
624+
`Exactly one package must register it with ownership 'own'.`
625+
);
626+
} else if (owners.length > 1) {
627+
const names = owners.map(c => c.packageId).join(', ');
628+
violations.push(
629+
`Object "${fqn}" has ${owners.length} owners [${names}] — exactly one is allowed.`
630+
);
631+
}
632+
}
633+
if (violations.length > 0) {
634+
throw new Error(
635+
`[Registry] single-owner-per-object check failed (ADR-0029):\n ` +
636+
violations.join('\n ')
637+
);
638+
}
639+
}
640+
598641
/**
599642
* Unregister all objects contributed by a package.
600643
*

packages/platform-objects/scripts/i18n-extract.config.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ import {
8181
} from '../src/audit/index.js';
8282

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

8688
// ── Metadata ──────────────────────────────────────────────────────────────
8789
import {
@@ -171,8 +173,7 @@ export default defineStack({
171173
SysJobRun,
172174
SysJobQueue,
173175

174-
// Integration
175-
SysWebhook,
176+
// Integration: sys_webhook moved to @objectstack/plugin-webhooks (ADR-0029 D8).
176177

177178
// Metadata
178179
SysMetadataObject,

packages/platform-objects/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* @objectstack/platform-objects/identity — user, session, org, team, api-key, ...
1111
* @objectstack/platform-objects/security — role, permission-set
1212
* @objectstack/platform-objects/audit — audit-log, presence
13-
* @objectstack/platform-objects/integration — webhook (outbound HTTP integrations)
13+
* @objectstack/platform-objects/integration — (empty; sys_webhook moved to @objectstack/plugin-webhooks per ADR-0029)
1414
* @objectstack/platform-objects/metadata — sys_metadata, sys_metadata_history
1515
* @objectstack/platform-objects/apps — built-in platform Apps (Setup, ...)
1616
*

packages/platform-objects/src/integration/index.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
/**
44
* platform-objects/integration — External Integration Platform Objects
55
*
6-
* Outbound HTTP webhooks (and, in the future, inbound receivers) live
7-
* here because they apply to every kernel — standalone, single-tenant,
8-
* and multi-tenant cloud projects alike. Any project can configure a
9-
* webhook to notify an external system on record events; the runtime
10-
* is provided by @objectstack/service-automation's built-in `http_request`
11-
* node (seeded by AutomationServicePlugin).
6+
* **Empty since ADR-0029 (K2.a).** `sys_webhook` moved to its owner,
7+
* `@objectstack/plugin-webhooks` (alongside `sys_webhook_delivery`), so the
8+
* plugin ships its data model and behavior as one unit. Import the schema
9+
* from `@objectstack/plugin-webhooks/schema` instead.
10+
*
11+
* The subpath (`@objectstack/plugin-webhooks/integration`) is retained as an
12+
* empty barrel to avoid churning the package `exports` map / tsup entries
13+
* during the incremental decomposition; it can be removed once the
14+
* decomposition completes (ADR-0029 K4).
1215
*/
1316

14-
export { SysWebhook } from './sys-webhook.object.js';
17+
export {};

packages/platform-objects/src/platform-objects.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
defaultPermissionSets,
2525
} from './security/index.js';
2626
import { SysAuditLog, SysPresence } from './audit/index.js';
27-
import { SysWebhook } from './integration/index.js';
27+
// sys_webhook moved to @objectstack/plugin-webhooks per ADR-0029 (K2.a).
2828
import {
2929
SysMetadata,
3030
SysMetadataHistoryObject,
@@ -52,7 +52,6 @@ const systemObjects = [
5252
['SysRolePermissionSet', SysRolePermissionSet, 'sys_role_permission_set'],
5353
['SysAuditLog', SysAuditLog, 'sys_audit_log'],
5454
['SysPresence', SysPresence, 'sys_presence'],
55-
['SysWebhook', SysWebhook, 'sys_webhook'],
5655
['SysMetadata', SysMetadata, 'sys_metadata'],
5756
['SysMetadataHistoryObject', SysMetadataHistoryObject, 'sys_metadata_history'],
5857
['SysSetting', SysSetting, 'sys_setting'],

packages/plugins/plugin-webhooks/src/schema.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
* Public schema subpath: `@objectstack/plugin-webhooks/schema`.
55
*
66
* Thin re-export barrel kept stable across refactors. The actual object
7-
* definition lives in `sys-webhook-delivery.object.ts` (matching the
8-
* `*.object.ts` convention used everywhere else in the monorepo for
9-
* `sys_*` schemas).
7+
* definitions live in `sys-webhook.object.ts` and
8+
* `sys-webhook-delivery.object.ts` (matching the `*.object.ts` convention
9+
* used everywhere else in the monorepo for `sys_*` schemas).
10+
*
11+
* `sys_webhook` moved here from `@objectstack/platform-objects` per
12+
* ADR-0029 (K2.a) so this plugin owns both of its objects.
1013
*
1114
* Note: callers that just need the runtime should import from the
1215
* package root (`@objectstack/plugin-webhooks`), which auto-registers
@@ -16,6 +19,7 @@
1619
* different runtime).
1720
*/
1821

22+
export { SysWebhook } from './sys-webhook.object.js';
1923
export {
2024
SysWebhookDelivery,
2125
SYS_WEBHOOK_DELIVERY,

packages/platform-objects/src/integration/sys-webhook.object.ts renamed to packages/plugins/plugin-webhooks/src/sys-webhook.object.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
1717
* targeted object, and dispatches outbound HTTP calls when matching
1818
* record events fire.
1919
*
20+
* Ownership (ADR-0029 K2.a): this object is **owned by
21+
* `@objectstack/plugin-webhooks`** — the plugin that consumes these rows —
22+
* alongside its sibling `sys_webhook_delivery`. It used to live in the
23+
* `@objectstack/platform-objects` monolith and be imported here; the
24+
* definition now lives with its owner so the plugin ships both data and
25+
* behavior as one unit.
26+
*
2027
* Platform-wide on purpose: every project (standalone, single-tenant,
2128
* cloud) can integrate with external systems (Slack, Stripe, internal
22-
* services) the same way. The control-plane-only `sys_environment*`
23-
* objects live in @objectstack/service-tenant; webhooks are
24-
* orthogonal and ship with every kernel.
29+
* services) the same way.
2530
*
2631
* @namespace sys
2732
*/

0 commit comments

Comments
 (0)