From 3d33cada3186c6d398ee450525267932eac93fef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 14:20:11 +0000 Subject: [PATCH 1/2] fix(service-messaging,plugin-audit): the service that writes sys_notification declares it (#4154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MessagingService.emit()` writes `sys_notification` on every call — it is the pipeline's single ingress (ADR-0030 L2). But the object was contributed to the manifest by AuditPlugin, parked there with a comment saying it would stay "until that [ADR-0030] migration lands". The migration landed; the parking did not move. That left a real deployment hole, because AuditPlugin is an OPTIONAL pair in the CLI's plugin table. Install messaging without audit and nothing registers the object, so the engine has no schema to issue DDL from and every notify() fails with `no such table: sys_notification`. AuditPlugin never wrote the row itself — it deliberately routes through this service's emit() ingress (`getMessaging()` in audit-writers.ts), and its own exclusion list already annotates the object as "messaging-owned (ADR-0030)". The contribution now lives with the writer, matching how every other service-owned platform object is handled here: service-job imports SysJob/SysJobRun, service-queue imports SysJobQueue, rest imports SysImportJob. Ownership of the DEFINITION is unchanged — the object stays in @objectstack/platform-objects and in PLATFORM_OBJECTS_BY_PACKAGE, because owning a definition and contributing it to a running kernel are different things. It is also added to provisionSystemTables so the table is created with the rest of the pipeline it heads rather than lazily on first write. Found while migrating notifications.hono.integration.test.ts to in-memory SQLite in #4065: that suite had to register the object itself to boot, which was the deployment bug in miniature. The workaround is deleted here — the suite now boots messaging alone and passes, which is the proof. A direct regression test pins the invariant at the plugin rather than three layers downstream, where it surfaced as a 404 or a `Find operation failed`. Verified it fails against the un-declared manifest before landing the fix. Verified: full forced suite including dogfood green (132/132), lint clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L5q3BZMpdiAueHf2emhDY9 --- .../messaging-declares-its-event-object.md | 36 +++++++++ .../plugins/plugin-audit/src/audit-plugin.ts | 17 +++-- .../notifications.hono.integration.test.ts | 22 ++---- .../services/service-messaging/package.json | 3 +- .../src/messaging-service-plugin.ts | 16 ++++ .../src/plugin-declares-event-object.test.ts | 75 +++++++++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 .changeset/messaging-declares-its-event-object.md create mode 100644 packages/services/service-messaging/src/plugin-declares-event-object.test.ts diff --git a/.changeset/messaging-declares-its-event-object.md b/.changeset/messaging-declares-its-event-object.md new file mode 100644 index 0000000000..9db4f8c176 --- /dev/null +++ b/.changeset/messaging-declares-its-event-object.md @@ -0,0 +1,36 @@ +--- +"@objectstack/service-messaging": patch +"@objectstack/plugin-audit": patch +--- + +fix(service-messaging,plugin-audit): the service that writes `sys_notification` is the one that declares it (#4154) + +`MessagingService.emit()` writes `sys_notification` on every call — it is the +pipeline's single ingress (ADR-0030 L2). But the object was contributed to the +manifest by **`AuditPlugin`**, parked there with a comment saying it would stay +"until that [ADR-0030] migration lands". The migration landed; the parking did +not move. + +That left a real deployment hole, because `AuditPlugin` is an **optional** pair +in the CLI's plugin table. Install messaging without audit and nothing registers +the object, so the engine has no schema to issue DDL from and every `notify()` +fails with `no such table: sys_notification`. AuditPlugin never wrote the row +itself — it deliberately routes through this service's `emit()` ingress +(`getMessaging()` in `audit-writers.ts`), and its own exclusion list already +annotates the object as "messaging-owned (ADR-0030)". + +The contribution now lives with the writer, matching how every other +service-owned platform object is handled in this repo — `service-job` imports +`SysJob`/`SysJobRun`, `service-queue` imports `SysJobQueue`, `rest` imports +`SysImportJob`. Ownership of the *definition* is unchanged: the object stays in +`@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`, because +owning a definition and contributing it to a running kernel are different +things. It is also added to the service's `provisionSystemTables`, so the table +is created with the rest of the pipeline it heads rather than lazily on the +first write. + +Found while migrating `notifications.hono.integration.test.ts` to in-memory +SQLite in #4065: that suite had to register the object itself to boot, which was +the deployment bug in miniature. The workaround is deleted in this change — the +suite now boots messaging alone and passes, which is the proof the product +declares what it writes. diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index b75afba246..e3889bee49 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -4,12 +4,15 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveLocalizationContext } from '@objectstack/core'; import type { IDataEngine } from '@objectstack/spec/contracts'; import { SysAuditLog, SysActivity, SysComment } from './objects/index.js'; -// `sys_notification` is contributed here but owned by platform-objects; it is -// being reworked by ADR-0030 messaging (notification→event), so it stays put -// until that migration lands. `sys_attachment` moved to @objectstack/service- -// storage (ADR-0052 §3 ownership: a file↔record link belongs with storage, not -// the compliance ledger). -import { SysNotification } from '@objectstack/platform-objects/audit'; +// `sys_notification` was parked here "until that [ADR-0030] migration lands". +// It has landed, so the contribution moved to @objectstack/service-messaging — +// the service that writes the row on every `emit()` (#4154). This plugin never +// wrote it directly (it routes through messaging's ingress, see +// `getMessaging()` in audit-writers.ts), and it is an OPTIONAL pair in the CLI, +// so registering another service's ingress object here made that service's +// core path depend on this plugin being installed. `sys_attachment` moved to +// @objectstack/service-storage for the same ownership reason (ADR-0052 §3: a +// file↔record link belongs with storage, not the compliance ledger). import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js'; /** @@ -37,7 +40,7 @@ export class AuditPlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysAuditLog, SysActivity, SysComment, SysNotification], + objects: [SysAuditLog, SysActivity, SysComment], // ADR-0029 D7 — contribute the Audit Logs entry into the Setup app's // `group_diagnostics` slot. The plugin owns sys_audit_log (K2). navigationContributions: [ diff --git a/packages/runtime/src/notifications.hono.integration.test.ts b/packages/runtime/src/notifications.hono.integration.test.ts index 30acccaa6d..7f894c4021 100644 --- a/packages/runtime/src/notifications.hono.integration.test.ts +++ b/packages/runtime/src/notifications.hono.integration.test.ts @@ -6,11 +6,9 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { MessagingServicePlugin, MessagingService } from '@objectstack/service-messaging'; -import { SysNotification } from '@objectstack/platform-objects'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; import { DriverPlugin } from './driver-plugin.js'; -import { AppPlugin } from './app-plugin.js'; /** * End-to-end regression for framework #3362 (`#3354 not effective on hono`). @@ -76,20 +74,12 @@ describe('in-app notifications over a real hono server (integration, #3362)', () // writes the inbox row synchronously so `emit()` is observable immediately. await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' }))); await kernel.use(new ObjectQLPlugin()); - // The L2 event object `sys_notification` is a PLATFORM object, declared in - // `@objectstack/platform-objects` — MessagingServicePlugin writes it but - // does not declare it, so this lean kernel (no platform-objects boot) has to - // register it or the engine has no schema to issue DDL from. Under the - // mingo driver this suite used before #4065 the omission was invisible: it - // auto-creates a table on first touch, so a missing declaration read as - // working. Registering the REAL object rather than a hand-copied stand-in - // keeps one schema (Prime Directive #12). - await kernel.use( - new AppPlugin({ - manifest: { id: 'com.test.notifications-e2e', name: 'Notifications E2E', version: '1.0.0' }, - objects: [SysNotification], - } as never), - ); + // No app plugin registers `sys_notification` here: MessagingServicePlugin + // contributes the L2 event it writes, so this lean kernel needs nothing + // extra (#4154). Until that move it was contributed by the OPTIONAL + // AuditPlugin, and this suite had to register the object itself — which is + // exactly the shape of the deployment bug: messaging's single ingress + // depending on another plugin being installed. await kernel.use(new MessagingServicePlugin({ reliableDelivery: false })); await kernel.use(fakeAuthPlugin()); // port 0 → OS-assigned free port; resolved via getPort() after listening. diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 2bdfd1a83f..c0feef5e50 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -2,7 +2,7 @@ "name": "@objectstack/service-messaging", "version": "17.0.0-rc.0", "license": "Apache-2.0", - "description": "Messaging Service for ObjectStack — outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.", + "description": "Messaging Service for ObjectStack \u2014 outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -19,6 +19,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/platform-objects": "workspace:*", "@objectstack/spec": "workspace:*" }, "devDependencies": { diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index da0488c3a1..20d67174bf 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -21,6 +21,15 @@ import { NotificationTemplate, HttpDelivery, } from './objects/index.js'; +// The L2 event this service writes on every `emit()`. It is OWNED by +// platform-objects (it is in `PLATFORM_OBJECTS_BY_PACKAGE`), and — like +// service-job with `SysJob` and service-queue with `SysJobQueue` — the service +// that owns the BEHAVIOUR is the one that contributes it to the manifest. +// It was parked in AuditPlugin until ADR-0030 landed; that migration is done +// (#4154), and AuditPlugin is an OPTIONAL pair in the CLI, so leaving it there +// meant this service's single ingress depended on another plugin being +// installed to register the table it writes. +import { SysNotification } from '@objectstack/platform-objects/audit'; export interface MessagingServicePluginOptions { /** @@ -135,6 +144,7 @@ export class MessagingServicePlugin implements Plugin { type: 'plugin', scope: 'system', objects: [ + SysNotification, InboxMessage, NotificationReceipt, NotificationDelivery, @@ -315,6 +325,12 @@ export class MessagingServicePlugin implements Plugin { const sync = (engine as unknown as { syncObjectSchema?: (name: string) => Promise }).syncObjectSchema; if (typeof sync !== 'function') return; const objects = [ + // The L2 event is provisioned with the rest of the pipeline it + // heads. Its table was previously created lazily by the SQL driver + // on the first `emit()` — which works only where the object is + // REGISTERED, and until #4154 that registration came from the + // optional AuditPlugin rather than from this service. + SysNotification, InboxMessage, NotificationReceipt, NotificationDelivery, diff --git a/packages/services/service-messaging/src/plugin-declares-event-object.test.ts b/packages/services/service-messaging/src/plugin-declares-event-object.test.ts new file mode 100644 index 0000000000..56f78a714b --- /dev/null +++ b/packages/services/service-messaging/src/plugin-declares-event-object.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #4154 — this service must DECLARE the L2 event object it writes. +// +// `MessagingService.emit()` writes `sys_notification` on every call; it is the +// pipeline's single ingress. The object is owned by `@objectstack/platform- +// objects`, but ownership of the DEFINITION is not the same as contributing it +// to a running kernel: the engine needs it in the registry before it can issue +// DDL, and nothing else in a lean boot puts it there. +// +// Until #4154 the contribution came from `AuditPlugin`, parked there with a +// comment saying it would stay "until that [ADR-0030] migration lands". The +// migration landed, the parking did not move, and AuditPlugin is an OPTIONAL +// pair in the CLI — so a deployment that installed messaging without audit had +// every `notify()` fail with `no such table: sys_notification`. AuditPlugin +// never wrote the row itself; it routes through this service's `emit()`. +// +// This test pins the invariant directly rather than through a booted stack, so +// a regression names the cause instead of surfacing three layers away as a 404 +// or a `Find operation failed`. + +import { describe, it, expect } from 'vitest'; +import { MessagingServicePlugin } from './messaging-service-plugin.js'; +import { NOTIFICATION_EVENT_OBJECT } from './messaging-service.js'; + +/** Capture what the plugin hands the `manifest` service during `init()`. */ +async function collectManifest(): Promise<{ objects: Array<{ name: string }> }> { + let captured: { objects?: Array<{ name: string }> } | undefined; + const ctx = { + logger: { info() {}, warn() {}, error() {}, debug() {} }, + registerService() {}, + // `init()` resolves `manifest` to register objects, and may probe other + // services; anything it does not need for this assertion resolves to + // undefined and the plugin's own optional-service guards handle it. + getService(name: string) { + if (name === 'manifest') { + return { register(m: { objects?: Array<{ name: string }> }) { captured = m; } }; + } + return undefined; + }, + hook() {}, + }; + + await new MessagingServicePlugin().init(ctx as never); + return { objects: captured?.objects ?? [] }; +} + +describe('MessagingServicePlugin declares the object it writes (#4154)', () => { + it('contributes the L2 event object to the manifest', async () => { + const { objects } = await collectManifest(); + const names = objects.map((o) => o?.name); + + // The exact name `emit()` writes — read from the service's own constant so + // the two cannot drift apart. + expect(names).toContain(NOTIFICATION_EVENT_OBJECT); + }); + + it('still contributes the rest of the pipeline alongside it', async () => { + const { objects } = await collectManifest(); + const names = objects.map((o) => o?.name); + + // Guards the other direction: adding the event object must not have + // displaced the objects this service already owned. + for (const name of [ + 'sys_inbox_message', + 'sys_notification_receipt', + 'sys_notification_delivery', + 'sys_notification_preference', + 'sys_notification_subscription', + 'sys_notification_template', + ]) { + expect(names).toContain(name); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eacb1f900b..372ebcc091 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2070,6 +2070,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../../core + '@objectstack/platform-objects': + specifier: workspace:* + version: link:../../platform-objects '@objectstack/spec': specifier: workspace:* version: link:../../spec From 741ee68cd7c730a6ad848353c88aef86ca0f0307 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 14:25:21 +0000 Subject: [PATCH 2/2] docs(plugin-audit): the objects barrel's sys_notification note has an answer now (#4154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `objects/index.ts` listed `sys_notification` under "Intentionally NOT moved here" with the reason "reworked by ADR-0030 messaging" — a pending state. The rework landed and the contribution moved to service-messaging, so the note now says where it went and why the definition still lives in platform-objects. Found by the docs-drift check on #4182 flagging plugin-audit. The nine docs it listed were all verified accurate and need no change — `audit-service.mdx` only references sys_audit_log/sys_activity, which this plugin still registers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L5q3BZMpdiAueHf2emhDY9 --- packages/plugins/plugin-audit/src/objects/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-audit/src/objects/index.ts b/packages/plugins/plugin-audit/src/objects/index.ts index c5a7e10d80..1331df6c66 100644 --- a/packages/plugins/plugin-audit/src/objects/index.ts +++ b/packages/plugins/plugin-audit/src/objects/index.ts @@ -8,7 +8,10 @@ * @mention hook). * * Intentionally NOT moved here: - * - `sys_notification` — reworked by ADR-0030 messaging. + * - `sys_notification` — the ADR-0030 rework landed, and the object is the + * messaging pipeline's L2 event. Its definition stays in platform-objects, + * and `@objectstack/service-messaging` — the only writer — is what + * contributes it to a kernel (#4154). This plugin no longer registers it. * - `sys_attachment` — a file↔record link belonging with service-storage's * sys_file; stays in platform-objects pending the storage-domain move. */