From 0329672b1eb1424a8c069520e493ebeb4dfdfa92 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 05:37:54 +0000 Subject: [PATCH 1/5] wip(messaging): resolve email/sms channel mounts per lookup Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude --- .../src/messaging-service-plugin.ts | 92 ++++++++++--- .../src/messaging-service.ts | 127 ++++++++++++++++-- 2 files changed, 193 insertions(+), 26 deletions(-) diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 6e5b72a739..4e177e2366 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'; import type { Plugin, PluginContext } from '@objectstack/core'; import type { IDataEngine } from '@objectstack/spec/contracts'; import { MessagingService } from './messaging-service.js'; +import type { MessagingChannel } from './channel.js'; import { createInboxChannel } from './inbox-channel.js'; import { SqlNotificationOutbox } from './sql-outbox.js'; import { SqlHttpOutbox } from './sql-http-outbox.js'; @@ -92,6 +93,40 @@ export interface MessagingServicePluginOptions { * await kernel.bootstrap(); * ``` */ +/** + * A resolve-on-use channel mount (#18050): ask the transport resolver on EVERY + * lookup, build the channel object once, announce the bind once. + * + * The shape is the one `createLazyCounterStore` (plugin-auth) established for + * the same hazard and AGENTS.md cites as the cure: nothing is resolved at + * start, so plugin order decides nothing. Two properties are deliberate: + * + * - **The transport presence is re-read every call, never memoised.** That is + * the whole fix — memoising it would restore the one-shot verdict in a new + * place. A transport that appears later mounts the channel; one that goes + * away unmounts it again, which lands on the SAME refusal a composition + * without the transport has always produced. + * - **The channel OBJECT is memoised**, so a mounted channel keeps its identity + * (and whatever it holds — template store handles, per-channel state) across + * lookups instead of being rebuilt per delivery row. + */ +function lazyChannelMount(opts: { + resolveTransport: () => unknown; + create: () => MessagingChannel; + announce: string; + logger: { info(msg: string): void }; +}): () => MessagingChannel | undefined { + let channel: MessagingChannel | undefined; + return () => { + if (!opts.resolveTransport()) return undefined; + if (!channel) { + channel = opts.create(); + opts.logger.info(opts.announce); + } + return channel; + }; +} + export class MessagingServicePlugin implements Plugin { name = 'com.objectstack.service.messaging'; /** @@ -253,24 +288,45 @@ export class MessagingServicePlugin implements Plugin { }); } - // Email channel (ADR-0030 P3): register when an `email` service is - // present. Resolved at kernel:ready so init order with the email plugin - // doesn't matter; absent email ⇒ no channel (a notify(channels:['email']) - // then reports "not registered" rather than silently no-opping). The - // dispatcher looks channels up dynamically, so registering after it is fine. + // Email channel (ADR-0030 P3) + SMS channel (#2780): mounted through a + // LAZY PROVIDER, so the mount TRACKS the transport service instead of + // recording a verdict about it (#18050). + // + // Both used to read the service registry once, inside this hook, and + // register the channel only `if (getEmail())`. The comment on that + // guard — "the dispatcher looks channels up dynamically, so registering + // after it is fine" — was true of the dispatcher and contradicted by + // the guard beneath it: the `if` ran EXACTLY ONCE, at `kernel:ready`, + // and nothing revisited it. A transport registered a moment later (a + // plugin ordered after this one registering from its own `kernel:ready` + // handler, `kernel:bootstrapped`, `kernel:listening`, or any runtime + // mount) never got its channel, and every `notify(channels:['email'])` + // was refused as "not registered" for the life of the process — the + // three-part "Startup registry reads" shape AGENTS.md names, cured here + // by its first cure: resolve where it is USED, not where you start. + // + // ⛔ What this does NOT change: while the transport is absent the + // provider answers nothing, so the channel is not mounted and fan-out + // refuses it exactly as before — no delivery row, and no + // `sys_notification.suppressed_channels` entry, because an absent mount + // is a COMPOSITION fact and that column answers "why can this TENANT + // not send" (#18041's boundary, pinned in unregistered-channel.test.ts). + // Whether a MOUNTED channel can send is the separate question + // `isAvailable` answers. if (typeof ctx.hook === 'function') { const templateStore = new NotificationTemplateStore({ getData }); ctx.hook('kernel:ready', async () => { - if (getEmail()) { - service.registerChannel(createEmailChannel({ getEmail, getData, store: templateStore, getDefaultTemplateLocale })); - ctx.logger.info('[messaging] email channel registered (renders sys_notification_template; notify `template` refs resolve sys_email_template by (name, locale) — resolved per recipient: sys_user.locale, else the deployment default)'); - } + service.registerChannelProvider('email', lazyChannelMount({ + resolveTransport: getEmail, + create: () => createEmailChannel({ getEmail, getData, store: templateStore, getDefaultTemplateLocale }), + announce: '[messaging] email channel registered (renders sys_notification_template; notify `template` refs resolve sys_email_template by (name, locale) — resolved per recipient: sys_user.locale, else the deployment default)', + logger: ctx.logger, + })); }); - // SMS channel (#2780): same pattern as email — register when an - // `sms` service (service-sms) is present at kernel:ready; absent - // sms ⇒ no channel, so a notify(channels:['sms']) reports "not - // registered" rather than silently no-opping. + // SMS channel (#2780): same pattern as email — mounted while an + // `sms` service (service-sms) is resolvable, unmounted while it is + // not, decided per lookup rather than once. const getSms = () => { try { return ctx.getService('sms'); @@ -279,10 +335,12 @@ export class MessagingServicePlugin implements Plugin { } }; ctx.hook('kernel:ready', async () => { - if (getSms()) { - service.registerChannel(createSmsChannel({ getSms, getData, store: templateStore, getDefaultTemplateLocale })); - ctx.logger.info('[messaging] sms channel registered (renders sys_notification_template — resolved per recipient: sys_user.locale, else the deployment default)'); - } + service.registerChannelProvider('sms', lazyChannelMount({ + resolveTransport: getSms, + create: () => createSmsChannel({ getSms, getData, store: templateStore, getDefaultTemplateLocale }), + announce: '[messaging] sms channel registered (renders sys_notification_template — resolved per recipient: sys_user.locale, else the deployment default)', + logger: ctx.logger, + })); }); } diff --git a/packages/services/service-messaging/src/messaging-service.ts b/packages/services/service-messaging/src/messaging-service.ts index 27c346e821..428fc9e935 100644 --- a/packages/services/service-messaging/src/messaging-service.ts +++ b/packages/services/service-messaging/src/messaging-service.ts @@ -200,8 +200,23 @@ export interface MessagingServiceContext extends MessagingChannelContext { * failures are reported in the result. The seams are shaped so those land * without breaking callers. */ +/** + * A channel mount that is answered AT LOOKUP TIME rather than at registration + * time (#18050) — `undefined` means "not mounted right now", asked again on + * the next lookup. + * + * Deliberately NOT exported: it is the shape + * {@link MessagingService.registerChannelProvider} takes, and a caller writes + * the arrow function inline. + */ +type ChannelProvider = () => MessagingChannel | undefined; + export class MessagingService { private readonly channels = new Map(); + /** [#18050] Channels resolved on every lookup — see {@link MessagingService.registerChannelProvider}. */ + private readonly channelProviders = new Map(); + /** [#18050] Provider ids whose throw has already been announced — the log is said once, the PROBE is not. */ + private readonly providerThrewAnnounced = new Set(); private readonly now: () => string; private readonly resolver: RecipientResolver; private readonly preferences: PreferenceResolver; @@ -362,28 +377,107 @@ export class MessagingService { return this.httpOutbox.list(filter); } - /** Register a channel implementation. A duplicate id warns and replaces. */ + /** + * Register a channel implementation. A duplicate id warns and replaces — + * including a lazy provider previously registered under the same id. + * + * The mount is IMMEDIATE and unconditional: the object handed in is what + * every later lookup gets. When the mount depends on something that may not + * exist yet — a transport service another plugin registers later in the + * same boot — use {@link MessagingService.registerChannelProvider}, which + * is asked again on every lookup instead. + */ registerChannel(channel: MessagingChannel): void { - if (this.channels.has(channel.id)) { + if (this.channels.has(channel.id) || this.channelProviders.has(channel.id)) { this.ctx.logger.warn(`[messaging] channel '${channel.id}' already registered; replacing`); } + this.channelProviders.delete(channel.id); this.channels.set(channel.id, channel); this.ctx.logger.info(`[messaging] channel registered: ${channel.id}`); } + /** + * Register a channel whose implementation is RESOLVED ON EVERY LOOKUP + * (#18050) — `resolve()` answers the channel, or `undefined` for "not + * mounted right now". + * + * ## Why a mount can't be decided once, at boot + * + * A composition fills its service registry incrementally, and `kernel:ready` + * is not the end of it: a plugin ordered after this one registers services + * from its OWN `kernel:ready` handler, and `kernel:bootstrapped` / + * `kernel:listening` run later still. A mount decided by reading that + * registry once — `if (getEmail()) registerChannel(…)` — turns "not there + * yet" into a permanent verdict nothing revisits: the transport arrives a + * moment later and every `notify` on that channel is refused as "not + * registered" for the life of the process. That is the three-part shape + * AGENTS.md's "Startup registry reads" section names (a read of a filling + * registry, a terminal conclusion from absence, and the conclusion + * recorded), and its first cure — resolve where it is USED, not where you + * start — is this method. + * + * ## What it deliberately does NOT change + * + * A channel that answers `undefined` is not mounted, so both fan-out paths + * refuse it exactly as they refuse a name nobody ever registered, and + * nothing is written to `sys_notification.suppressed_channels`: an absent + * mount stays a COMPOSITION fact (#18050 / #18041's boundary). Whether a + * MOUNTED channel can send for a given tenant is the separate question + * {@link MessagingChannel.isAvailable} answers. + * + * ⛔ No cache, for the reason the availability probe carries none: the + * answer is read from in-memory composition state, so caching it would + * serve the stale verdict this method exists to abolish. Provider authors + * keep the probe cheap (a service-registry closure call) and memoise the + * CHANNEL OBJECT, never the presence of the transport. + */ + registerChannelProvider(id: string, resolve: ChannelProvider): void { + if (this.channels.has(id) || this.channelProviders.has(id)) { + this.ctx.logger.warn(`[messaging] channel '${id}' already registered; replacing`); + } + this.channels.delete(id); + this.channelProviders.set(id, resolve); + this.ctx.logger.info(`[messaging] channel provider registered: ${id} (resolved per lookup)`); + } + /** Remove a channel. No-op when absent. */ unregisterChannel(id: string): void { this.channels.delete(id); + this.channelProviders.delete(id); + this.providerThrewAnnounced.delete(id); } - /** Look up a channel by id. */ + /** + * Look up a channel by id, asking a lazy provider when one is registered + * (#18050). A provider that throws is "not mounted right now" — said once + * per id, then asked again on the next lookup like any other absence. + */ getChannel(id: string): MessagingChannel | undefined { - return this.channels.get(id); + const direct = this.channels.get(id); + if (direct) return direct; + const provider = this.channelProviders.get(id); + if (!provider) return undefined; + try { + return provider() ?? undefined; + } catch (err) { + if (!this.providerThrewAnnounced.has(id)) { + this.providerThrewAnnounced.add(id); + this.ctx.logger.warn( + `[messaging] channel provider '${id}' threw ` + + `(${(err as Error)?.message ?? String(err)}); treating '${id}' as not mounted`, + ); + } + return undefined; + } } - /** All registered channel ids. */ + /** The channel ids that resolve RIGHT NOW — a provider that answers nothing is not listed. */ getRegisteredChannels(): string[] { - return [...this.channels.keys()]; + const ids = new Set(this.channels.keys()); + for (const id of this.channelProviders.keys()) { + if (this.getChannel(id)) ids.add(id); + } + return [...ids]; } /* ------------------------------------------------------------------ */ @@ -1062,7 +1156,7 @@ export class MessagingService { const available: string[] = []; const suppressed: ChannelSuppression[] = []; for (const id of requested) { - const channel = this.channels.get(id); + const channel = this.getChannel(id); if (!channel?.isAvailable) { available.push(id); continue; @@ -1138,9 +1232,24 @@ export class MessagingService { // channel. Said ONCE per channel below, with the volume it refused — // which is the number an operator needs to size the misconfiguration. const refused = new Map(); + // [#18050] One mount answer per CHANNEL per emit, not one per + // `(recipient × channel)` pair: a lazy provider (see + // {@link MessagingService.registerChannelProvider}) is a closure call, + // and asking it 500 times for one audience would also let one emit + // report a channel as both refused and enqueued — which is exactly the + // ambiguity the once-per-channel refusal count below exists to remove. + const mounted = new Map(); + const isMounted = (id: string): boolean => { + let answer = mounted.get(id); + if (answer === undefined) { + answer = this.getChannel(id) !== undefined; + mounted.set(id, answer); + } + return answer; + }; for (const { recipient, channels, notBefore, digest } of targets) { for (const channel of channels) { - if (!this.channels.has(channel)) { + if (!isMounted(channel)) { refused.set(channel, (refused.get(channel) ?? 0) + 1); deliveries.push({ channel, @@ -1257,7 +1366,7 @@ export class MessagingService { for (const { recipient, channels } of targets) { for (const channelId of channels) { - const channel = this.channels.get(channelId); + const channel = this.getChannel(channelId); if (!channel) { deliveries.push({ channel: channelId, From 6eed351ce4da56fd94c7e27fe4e97d1a7c83bbb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 05:41:38 +0000 Subject: [PATCH 2/5] test(messaging): pin the mount as an answer, not a verdict Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude --- .../src/lazy-channel-mount.test.ts | 263 ++++++++++++++++++ .../src/messaging-service-plugin.test.ts | 46 ++- 2 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 packages/services/service-messaging/src/lazy-channel-mount.test.ts diff --git a/packages/services/service-messaging/src/lazy-channel-mount.test.ts b/packages/services/service-messaging/src/lazy-channel-mount.test.ts new file mode 100644 index 0000000000..9b11b7bb7e --- /dev/null +++ b/packages/services/service-messaging/src/lazy-channel-mount.test.ts @@ -0,0 +1,263 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#18050] A channel MOUNT is resolved per lookup — it is not a verdict +// recorded once while the service registry was still filling. +// +// ## What was wrong +// +// `messaging-service-plugin.ts` registered the email and SMS channels inside a +// `kernel:ready` hook, behind `if (getEmail())` / `if (getSms())`. The comment +// above that guard reasoned "the dispatcher looks channels up dynamically, so +// registering after it is fine" — true of the dispatcher, and contradicted by +// the guard beneath it: the `if` ran exactly ONCE and nothing revisited it. A +// transport that registered a moment later — a plugin ordered after this one +// registering from its own `kernel:ready` handler, `kernel:bootstrapped`, +// `kernel:listening`, or any runtime mount — never got its channel, and every +// `notify` naming it was refused as "not registered" for the life of the +// process. That is the three-part shape AGENTS.md's "Startup registry reads" +// section names, and `registerChannelProvider` is its first cure applied here: +// resolve where it is USED, not where you start. +// +// ## Why the facts below are pinned on ONE pass +// +// Each alone is satisfied by an implementation broken in a different direction: +// +// * "the late transport delivered" — also true of an implementation that +// mounts every named channel unconditionally, which would turn a typo into +// a silently no-opping channel. +// * "the absent transport was refused" — also true of the OLD code, which is +// the state this file exists to leave behind. +// * "the rows reached the dispatcher" — also true of a fan-out that wrote +// rows the dispatcher could only dead-letter, i.e. the #18050 defect the +// durable path already fixed. +// +// Only the conjunction — one service, one outbox, one dispatcher tick, the +// transport arriving BETWEEN two emits — says the mount tracks the transport +// rather than a snapshot of it. +// +// ## The boundary this must not move +// +// ⛔ An unmounted channel is still REFUSED, never suppressed. Whether a MOUNTED +// channel can send for a tenant is `isAvailable`'s question and lands in +// `sys_notification.suppressed_channels`; whether a channel is mounted at all +// is a COMPOSITION fact and lands in a failed `DeliveryOutcome` with no row and +// no column (#18041's settlement, pinned from the other side in +// `unregistered-channel.test.ts` and `channel-availability.test.ts`). The +// second and third tests below hold that line while the mount moves. + +import { describe, it, expect } from 'vitest'; +import { MessagingService } from './messaging-service.js'; +import { MemoryNotificationOutbox } from './memory-outbox.js'; +import { NotificationDispatcher } from './dispatcher.js'; +import type { Delivery, MessagingChannel } from './channel.js'; + +function silentLogger() { + return { info: () => {}, warn: () => {}, error: () => {} }; +} + +/** A data engine double that captures the `sys_notification` insert — see `unregistered-channel.test.ts`. */ +function capturingEngine() { + const inserts: Array<{ object: string; row: Record }> = []; + return { + inserts, + engine: { + async insert(object: string, row: Record) { + inserts.push({ object, row }); + return { id: `evt_${inserts.length}`, ...row }; + }, + async find() { + return []; + }, + } as never, + }; +} + +function channelDouble(id: string): { channel: MessagingChannel; sent: Delivery[] } { + const sent: Delivery[] = []; + return { + sent, + channel: { + id, + async send(_ctx, delivery) { + sent.push(delivery); + return { ok: true }; + }, + }, + }; +} + +const EMIT = { + topic: 'deal.won', + audience: ['user_1', 'user_2'], + channels: ['inbox', 'email'], + organizationId: 'org_1', + payload: { title: 'Deal closed' }, +}; + +describe('a channel mounted through a provider (#18050)', () => { + it('mounts the moment its transport appears — mid-process, between two emits — and the dispatcher DELIVERS those rows', async () => { + const outbox = new MemoryNotificationOutbox(1); + const data = capturingEngine(); + const service = new MessagingService({ + logger: silentLogger(), + outbox, + getData: () => data.engine, + }); + service.registerChannel(channelDouble('inbox').channel); + + // The transport this composition has not registered YET — the plugin's + // `getEmail()` in miniature, and the only moving part in this test. + let transport: MessagingChannel | undefined; + service.registerChannelProvider('email', () => transport); + + // (1) While it is absent the answer is the one a composition that never + // registers it has always produced: refused, no row written, so + // there is nothing for the dispatcher to dead-letter. + const before = await service.emit(EMIT); + expect((await outbox.list()).filter((r) => r.channel === 'email')).toHaveLength(0); + expect( + before.deliveries + .filter((d) => d.channel === 'email') + .map((d) => ({ recipient: d.recipient, ok: d.ok, error: d.error })) + .sort((a, b) => a.recipient.localeCompare(b.recipient)), + ).toEqual([ + { recipient: 'user_1', ok: false, error: "channel 'email' not registered" }, + { recipient: 'user_2', ok: false, error: "channel 'email' not registered" }, + ]); + + // (2) The transport registers LATER than `kernel:ready`. Under the old + // one-shot guard this moment was unreachable: the mount decision had + // already been taken and recorded as a non-registration. + const email = channelDouble('email'); + transport = email.channel; + + const after = await service.emit(EMIT); + expect(after.deliveries.filter((d) => d.channel === 'email').every((d) => d.ok)).toBe(true); + expect( + (await outbox.list()).filter((r) => r.channel === 'email').map((r) => r.recipientId).sort(), + ).toEqual(['user_1', 'user_2']); + + // (3) …and those rows are DELIVERED, not dead-lettered on attempt one. + // The dispatcher resolves through the SAME lookup this service + // answers (`ChannelRegistry.getChannel`), so the old guard's claim — + // "the dispatcher looks channels up dynamically" — is finally true + // end to end rather than contradicted by the code beneath it. + const dispatcher = new NotificationDispatcher({ + nodeId: 'node-test', + outbox, + channels: service, + channelContext: { logger: silentLogger() }, + intervalMs: 10_000, + }); + await dispatcher.tick(); + + const settled = (await outbox.list()).filter((r) => r.channel === 'email'); + expect(settled.map((r) => r.status)).toEqual(['success', 'success']); + expect(settled.map((r) => r.attempts)).toEqual([1, 1]); + expect(email.sent.map((d) => d.recipient).sort()).toEqual(['user_1', 'user_2']); + }); + + it('unmounts again when the transport goes away — the same refusal, not a new failure mode', async () => { + // Symmetry is the point: a provider is a question asked every time, so + // the answer has to be allowed to change back. An implementation that + // memoised the PRESENCE (rather than the channel object) would pass the + // test above and fail this one, having simply moved the one-shot verdict + // to a later moment. + const outbox = new MemoryNotificationOutbox(1); + const data = capturingEngine(); + const service = new MessagingService({ logger: silentLogger(), outbox, getData: () => data.engine }); + service.registerChannel(channelDouble('inbox').channel); + + const email = channelDouble('email'); + let transport: MessagingChannel | undefined = email.channel; + service.registerChannelProvider('email', () => transport); + + expect(service.getRegisteredChannels()).toContain('email'); + expect(service.getChannel('email')).toBe(email.channel); + + transport = undefined; + + expect(service.getRegisteredChannels()).not.toContain('email'); + expect(service.getChannel('email')).toBeUndefined(); + + const result = await service.emit(EMIT); + expect((await outbox.list()).filter((r) => r.channel === 'email')).toHaveLength(0); + expect(result.deliveries.filter((d) => d.channel === 'email').map((d) => d.error)).toEqual([ + "channel 'email' not registered", + "channel 'email' not registered", + ]); + }); + + it('⛔ records NO suppression while unmounted — the event row keeps the column set it had', async () => { + // #18041's settlement, held while the mount became dynamic: an absent + // mount is a COMPOSITION fact, identical for every tenant in the + // process, so it must not enter a per-tenant availability column. A + // stack whose `sys_notification` predates that column would answer + // INVALID_FIELD and lose the notification outright. + const outbox = new MemoryNotificationOutbox(1); + const data = capturingEngine(); + const service = new MessagingService({ logger: silentLogger(), outbox, getData: () => data.engine }); + service.registerChannel(channelDouble('inbox').channel); + service.registerChannelProvider('email', () => undefined); + + const result = await service.emit({ ...EMIT, audience: ['user_1'] }); + + expect(result.suppressed).toEqual([]); + expect(data.inserts).toHaveLength(1); + expect(Object.prototype.hasOwnProperty.call(data.inserts[0].row, 'suppressed_channels')).toBe(false); + }); + + it('treats a provider that THROWS as not mounted, says so once, and keeps asking', async () => { + // A resolver reaching a service registry can throw (`getService` does, + // for an unregistered name). Fail-closed on the mount, fail-quiet on the + // log — and ⛔ never record the failure as a verdict: the very next + // lookup asks again, which is how a transport that recovers gets its + // channel back without a restart. + const warnings: string[] = []; + const service = new MessagingService({ + logger: { ...silentLogger(), warn: (...a: unknown[]) => warnings.push(String(a[0])) }, + getData: () => undefined, + }); + const email = channelDouble('email'); + let broken = true; + service.registerChannelProvider('email', () => { + if (broken) throw new Error('email service exploded'); + return email.channel; + }); + + expect(service.getChannel('email')).toBeUndefined(); + expect(service.getChannel('email')).toBeUndefined(); + const lines = warnings.filter((w) => w.includes("channel provider 'email' threw")); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('email service exploded'); + + broken = false; + expect(service.getChannel('email')).toBe(email.channel); + }); + + it('a directly registered channel and a provider replace each other under one id', async () => { + // Both registries answer the same `getChannel`, so an id can only mean + // one thing at a time — otherwise a stale direct registration would + // shadow the provider that replaced it, which is the one-shot verdict + // again wearing a different hat. + const warnings: string[] = []; + const service = new MessagingService({ + logger: { ...silentLogger(), warn: (...a: unknown[]) => warnings.push(String(a[0])) }, + getData: () => undefined, + }); + const direct = channelDouble('email'); + const lazy = channelDouble('email'); + + service.registerChannel(direct.channel); + service.registerChannelProvider('email', () => lazy.channel); + expect(service.getChannel('email')).toBe(lazy.channel); + expect(service.getRegisteredChannels().filter((id) => id === 'email')).toHaveLength(1); + + service.registerChannel(direct.channel); + expect(service.getChannel('email')).toBe(direct.channel); + + service.unregisterChannel('email'); + expect(service.getChannel('email')).toBeUndefined(); + expect(warnings.filter((w) => w.includes("already registered; replacing"))).toHaveLength(2); + }); +}); diff --git a/packages/services/service-messaging/src/messaging-service-plugin.test.ts b/packages/services/service-messaging/src/messaging-service-plugin.test.ts index c97371ccd7..7f5dd4b29e 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.test.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.test.ts @@ -121,7 +121,8 @@ function provisionCtx() { ['manifest', { register() {} }], ]); const readyHooks: Array<() => Promise | void> = []; - const logger = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; } }; + const logs: string[] = []; + const logger = { info(msg: string) { logs.push(String(msg)); }, warn() {}, error() {}, debug() {}, child() { return logger; } }; const ctx = { logger, getService(name: string) { return services.get(name); }, @@ -130,7 +131,7 @@ function provisionCtx() { if (event === 'kernel:ready') readyHooks.push(fn); }, } as any; - return { ctx, engine, synced, fireReady: async () => { for (const fn of readyHooks) await fn(); } }; + return { ctx, engine, synced, logs, fireReady: async () => { for (const fn of readyHooks) await fn(); } }; } describe('MessagingServicePlugin — email/sms channel registration (kernel:ready)', () => { @@ -152,6 +153,47 @@ describe('MessagingServicePlugin — email/sms channel registration (kernel:read const messaging: any = ctx.getService('messaging'); expect(messaging.getRegisteredChannels()).not.toContain('sms'); }); + + // [#18050] The two pins above say what the mount answers at `kernel:ready`. + // These say it is an ANSWER and not a VERDICT: the plugin reads the service + // registry on every lookup, so a transport that registers after + // `kernel:ready` — another plugin's own ready handler, `kernel:bootstrapped`, + // `kernel:listening`, a runtime mount — still gets its channel. Before this, + // `if (getSms())` ran once and recorded the absence as a permanent + // non-registration, and every `notify(channels:['sms'])` for the life of the + // process was refused as "not registered" with the transport sitting right + // there in the registry. + it('mounts the sms channel when the sms service arrives AFTER kernel:ready (#18050)', async () => { + const { ctx, fireReady } = provisionCtx(); + await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx); + await fireReady(); + const messaging: any = ctx.getService('messaging'); + expect(messaging.getRegisteredChannels()).not.toContain('sms'); + + ctx.registerService('sms', { async send() { return { status: 'sent' }; } }); + + expect(messaging.getRegisteredChannels()).toContain('sms'); + expect(messaging.getChannel('sms')?.id).toBe('sms'); + }); + + it('mounts the email channel when the email service arrives AFTER kernel:ready, and builds it ONCE (#18050)', async () => { + const { ctx, fireReady, logs } = provisionCtx(); + await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx); + await fireReady(); + const messaging: any = ctx.getService('messaging'); + expect(messaging.getRegisteredChannels()).not.toContain('email'); + + ctx.registerService('email', { async send() { return { id: 'mail_1' }; } }); + + const first = messaging.getChannel('email'); + expect(first?.id).toBe('email'); + // The mount is re-decided per lookup; the channel OBJECT is not rebuilt + // per lookup, so it keeps its identity (and its template-store handle) + // and announces the bind exactly once however often it is consulted. + expect(messaging.getChannel('email')).toBe(first); + expect(messaging.getRegisteredChannels()).toContain('email'); + expect(logs.filter((l) => l.includes('email channel registered'))).toHaveLength(1); + }); }); describe('MessagingServicePlugin — system table provisioning', () => { From 661c9b611541b43f3bcfd0adc44c4ffd313c1c3a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 05:50:29 +0000 Subject: [PATCH 3/5] fix(messaging): mount the email and SMS channels per lookup, not once at kernel:ready The plugin registered both channels behind a guard that ran exactly once, at kernel:ready, so a transport service registering later in the same boot never got its channel and every notify naming it was refused for the life of the process. MessagingService.registerChannelProvider resolves a mount on every lookup; an absent transport keeps today's refusal, writes no delivery row and records no suppression. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude --- .changeset/lazy-messaging-channel-mounts.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/lazy-messaging-channel-mounts.md diff --git a/.changeset/lazy-messaging-channel-mounts.md b/.changeset/lazy-messaging-channel-mounts.md new file mode 100644 index 0000000000..3823946a16 --- /dev/null +++ b/.changeset/lazy-messaging-channel-mounts.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-messaging': patch +--- + +Mount the email and SMS channels per lookup instead of deciding once at `kernel:ready` + +The messaging plugin registered its email and SMS channels behind `if (getEmail())` / +`if (getSms())` inside a `kernel:ready` hook. That guard ran exactly once, so a transport +service that registered later in the same boot — from a plugin ordered after this one, from +`kernel:bootstrapped` / `kernel:listening`, or at runtime — never got its channel, and every +`notify` naming that channel was refused as "not registered" for the life of the process. + +`MessagingService.registerChannelProvider(id, resolve)` mounts a channel that is resolved on +every lookup, and the plugin now mounts both channels through it: the mount tracks the +transport instead of recording a verdict about it, and the dispatcher — which has always +looked channels up dynamically — picks up a late transport without a restart. A composition +that never registers the transport is unchanged: the channel is not mounted, fan-out refuses +it, no delivery row is written, and nothing is recorded in +`sys_notification.suppressed_channels`. From d4177926b88bcdf0fc4e39afcd2c035a3642a90a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 05:58:44 +0000 Subject: [PATCH 4/5] chore(changeset): grade the messaging change minor, not patch The diff adds MessagingService.registerChannelProvider to an already-published class. A purely additive widening of a published surface takes at least minor (maintainer ruling 2026-09-04, decision batch #35), and the PR declares clause two yes, so patch was a self-contradiction inside one PR. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude --- .changeset/lazy-messaging-channel-mounts.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/lazy-messaging-channel-mounts.md b/.changeset/lazy-messaging-channel-mounts.md index 3823946a16..b246c1d612 100644 --- a/.changeset/lazy-messaging-channel-mounts.md +++ b/.changeset/lazy-messaging-channel-mounts.md @@ -1,5 +1,5 @@ --- -'@objectstack/service-messaging': patch +'@objectstack/service-messaging': minor --- Mount the email and SMS channels per lookup instead of deciding once at `kernel:ready` @@ -10,6 +10,8 @@ service that registered later in the same boot — from a plugin ordered after t `kernel:bootstrapped` / `kernel:listening`, or at runtime — never got its channel, and every `notify` naming that channel was refused as "not registered" for the life of the process. +New public surface (which is why this grades `minor` and not `patch`, per the 2026-09-04 ruling +that a purely additive widening of a published surface takes at least a minor): `MessagingService.registerChannelProvider(id, resolve)` mounts a channel that is resolved on every lookup, and the plugin now mounts both channels through it: the mount tracks the transport instead of recording a verdict about it, and the dispatcher — which has always From 6166e303cec1416f0f9ab3e772b0fabbafd8a85f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 06:03:49 +0000 Subject: [PATCH 5/5] test(messaging): carry the slot contract on the two new service lookups The #18050 plugin tests wrote `const messaging: any = ctx.getService(...)`, which is the erasure shape slot-lookup bans; the file is grandfathered for its existing sites only, so the ratchet grew 2 to 4. Typed at the source with the spelling this file already uses; the two grandfathered sites are left to the sweep batch that owns them and the baseline is untouched. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude --- .../service-messaging/src/messaging-service-plugin.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/services/service-messaging/src/messaging-service-plugin.test.ts b/packages/services/service-messaging/src/messaging-service-plugin.test.ts index 7f5dd4b29e..df3b695737 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.test.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.test.ts @@ -167,7 +167,7 @@ describe('MessagingServicePlugin — email/sms channel registration (kernel:read const { ctx, fireReady } = provisionCtx(); await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx); await fireReady(); - const messaging: any = ctx.getService('messaging'); + const messaging = ctx.getService('messaging') as MessagingService; expect(messaging.getRegisteredChannels()).not.toContain('sms'); ctx.registerService('sms', { async send() { return { status: 'sent' }; } }); @@ -180,7 +180,7 @@ describe('MessagingServicePlugin — email/sms channel registration (kernel:read const { ctx, fireReady, logs } = provisionCtx(); await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx); await fireReady(); - const messaging: any = ctx.getService('messaging'); + const messaging = ctx.getService('messaging') as MessagingService; expect(messaging.getRegisteredChannels()).not.toContain('email'); ctx.registerService('email', { async send() { return { id: 'mail_1' }; } });