Skip to content

Commit e07eecf

Browse files
fix(messaging): mount the email and SMS channels per lookup, not once at kernel:ready (#18272)
Part of #18050 Clause-②: yes This delivers the card's SECOND half — registration read as a one-shot verdict. The first half (the outbox fan-out writing a delivery row for a channel nobody registered) landed in #18081 and is on `main` today, so it is not what this PR changes. See "Premise re-verified" below: that half of the card's stated premise has DRIFTED and no longer reproduces. ## Premise re-verified on today's `origin/main`, driven rather than read Re-derived by symbol (every line number on the card is a stale anchor), at merge base `e592b4b6b`: | the card says | measured today | |---|---| | inline P0 `fanOut` checks registration | holds — `getChannel(channelId)`, refusing one line down | | outbox P1 `enqueueDeliveries` has NO check | **DRIFTED** — #18081 added one; the loop refuses and counts | | an unregistered channel gets one row per recipient, dead-lettered on attempt one | **no longer reproduces** | | registration is a ONE-SHOT VERDICT (`if (getEmail())` at `kernel:ready`) | holds, unchanged — this PR's target | Both directions were driven end to end through a real `MemoryNotificationOutbox` and a real `NotificationDispatcher.tick()`, not concluded from the shape of the loops. A temporary probe emitted on `['inbox','email']` with `email` never registered: ``` leg A — today's main, guard in place rows_written_for_unregistered_channel=0 after_one_tick=[] emit_counts={"enqueued":2,"failed":2,"suppressed":[]} leg B — the same tree with the landed guard REMOVED (the shape the card describes) rows_written_for_unregistered_channel=2 after_one_tick=[{"status":"dead","attempts":1,"error":"channel 'email' not registered"}, {"status":"dead","attempts":1,"error":"channel 'email' not registered"}] emit_counts={"enqueued":4,"failed":0,"suppressed":[]} ``` So the card's reported symptom is real and is already cured on `main`; the probe file was deleted after both legs ran (restore proven by blob identity, see Evidence). ## What was wrong with the half this PR takes `messaging-service-plugin.ts` mounted 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 later in the same boot — a plugin ordered after this one registering from its own `kernel:ready` handler, `kernel:bootstrapped`, `kernel:listening`, or a runtime mount — never got its channel, and every `notify` naming it was refused as "not registered" for the life of the process, with the transport sitting in the registry. That is the three-part shape AGENTS.md's "Startup registry reads" section names: a read of a still-filling registry, a terminal conclusion from absence, and the conclusion recorded — here as a non-registration nothing revisits. `pnpm check:startup-registry-verdict` is green on it both before and after, because the AST rule reads a recorded verdict and a mount that never happens leaves nothing for it to read. ## The change `MessagingService.registerChannelProvider(id, resolve)` mounts a channel that is resolved on EVERY lookup; `getChannel` asks the provider, and `getRegisteredChannels` lists only what resolves right now. The plugin mounts both channels through it, memoising the channel OBJECT (so it keeps its identity and its template-store handle) and never the transport's PRESENCE — that re-read is the whole fix. This is AGENTS.md's first cure, in the shape `createLazyCounterStore` (plugin-auth) established: nothing is resolved at start, so plugin order decides nothing. The dispatcher reads the same lookup through `ChannelRegistry.getChannel`, so the old guard's claim is finally true end to end rather than contradicted by the code beneath it. ## What this deliberately does NOT move ⛔ An unmounted channel is still REFUSED, never suppressed. A composition that never registers the transport behaves exactly as it does today: the channel is not mounted, both fan-out paths refuse it with the same failed `DeliveryOutcome`, no `sys_notification_delivery` row is written, and nothing is recorded in `sys_notification.suppressed_channels` — an absent mount is a COMPOSITION fact, identical for every tenant in the process, and that column answers "why can this TENANT not send" (#18041's settlement). Whether a MOUNTED channel can send is the separate question `isAvailable` answers, and this PR does not touch it. `channel-availability.test.ts` and `unregistered-channel.test.ts` are unchanged and green: the boundary they pin was held, not rewritten to make room. Both existing plugin pins ("registers the sms channel when an sms service is present" / "does NOT register the sms channel when no sms service exists") are also unchanged and green — this PR changes WHEN the mount is decided, not what it decides. The alternative fork — mounting both channels unconditionally and letting `isAvailable` answer `transport_not_configured` — was rejected for exactly that reason: it would move a deployment-wide composition fact into the per-tenant suppression column, re-opening what #18041 settled, and (SMS implements no `isAvailable`) would turn an absent SMS transport into a delivery row recorded as SUCCESS with nothing sent. ## Evidence **Suite** (affected package, full): `pnpm --filter @objectstack/service-messaging test` — `Test Files 44 passed (44) · Tests 467 passed (467)`, VERDICT command-exit 0. `pnpm --filter @objectstack/service-messaging typecheck` — VERDICT command-exit 0, and `tsc -p tsconfig.json --listFiles` puts both edited test files in the program (1 hit each), so the green is attributable. **Ablations** — one-off, direction predicted RED and observed RED, each proven to reach disk by anchor counts and restored by STATE (not by an exit code). No `dist` leg is owed: the suite is in-package and vitest resolves these modules from `src`. | mutation | anchor before/after | result | restore | |---|---|---|---| | M1 — the plugin decides the mount ONCE again (`presentOnce`) | 1/0, `presentOnce` 0/2 | `Tests 2 failed, 7 passed` — "expected [ 'inbox' ] to include 'sms'" | on-disk == HEAD blob `4e177e236`, `git diff HEAD` empty, 9 passed after | | M2 — the service MEMOISES the provider answer (the verdict, moved) | 1/0, `resolvedOnce` 0/3 | `Tests 1 failed, 4 passed` — "expected [ 'inbox', 'email' ] to not include 'email'" | on-disk == HEAD blob `428fc9e93`, 5 passed after | | premise probe leg B — the landed #18081 guard removed | 1/0 | 2 rows written, both `dead` on attempt 1 | on-disk == HEAD blob `428fc9e93`, anchor back to 1 | **Gates — derived 64 / run 20 of them / NOT MEASURED 1 / UNRUN 44** (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived after the changeset existed; exit codes captured before any pipe, verdicts read from each gate's own output). 14 of the 15 exit 0: `check:nul-bytes`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:engine-double-contract`, `check:type-check-coverage`, `check-registry-log-declared`, `check-plugin-teardown-shape`, `check-undeclared-dep-imports`, `check-empty-changeset --base origin/main`, `check-changeset-no-major --base origin/main`, `check-adr-0087-registration --base origin/main`, `check-closing-keyword-parity`, `check:published-files`, `check-comment-mask-adoption`. Plus `pnpm check:startup-registry-verdict` (exit 0), run because it owns this defect shape although the derivation does not name it. ⊘ NOT MEASURED: `pnpm check:type-check-debt` exited **3 — PREREQUISITE NOT MET** (it wants the whole-workspace build closure lint.yml builds first). Exit 3 is not a pass and not a finding; nothing was measured. The 44 UNRUN are the Lint and Repo Gates farm, left to CI rather than enumerated locally — see the follow-up section: one of them turned out to be a real finding about this diff, and the count above already includes the gates that round added. **Control characters**: a self-scan over the diff's files matched nothing. ## Clause-②: yes — re-derived from the DELIVERED diff, with controls ⛔ Not from the word `export` and ⛔ not from a bundle grep. Probed through the published entry (`exports["."]` to `dist/index.js` / `dist/index.d.ts`) after building the package: | probe | reading | |---|---| | added `export` lines across the whole diff | **0** — and that is the trap, not the answer | | `src/index.ts` (barrel re-export list) | **unchanged**, 0 lines | | positive control — `MessagingService.prototype.registerChannelProvider` | **function**, and declared on the exported class in `dist/index.d.ts` | | positive control — an already-published member (`registerChannel`) | function — the probe is not new-blind | | negative control — `lazyChannelMount` (plugin-local helper) | `undefined`, and absent from the d.ts | | negative control — the `ChannelProvider` type | not importable (not in the export list) | | negative control — a name that does not exist | `undefined` — the probe can answer no | ⇒ the published surface of an already-published entry grows by one callable member, so the direction is a WIDENING and the honest declaration is `yes`. ⚠️ The card's claim comment declares `Clause-②: no` as a prediction; that prediction is falsified by this diff, and correcting the card's declaration and carrying the label on the card side are the seat's acts, not this branch's. `needs:contract-review` is on this PR. ## Not in scope, recorded `email-channel.ts` and `sms-channel.ts` still answer `{ ok: true }` when their transport is absent, so a direct `send()` from the dispatcher can record a delivery as success with nothing sent. The card's body already records it as not this card's fix, and this PR narrows rather than widens its reach: with the mount tracking the transport, fan-out cannot reach an unmounted channel at all. ## Follow-up after review feedback (head `6166e303c`) Two reds were reported on head `661c9b611` and both were mine. Neither was answered by loosening anything. **1. `Check Changeset` — the changeset grade was the side that was wrong.** The PR declares clause ② `yes`, and `.changeset/lazy-messaging-channel-mounts.md` graded `patch`. Under the maintainer ruling of 2026-09-04 (decision batch #35, on #15294) a purely additive widening of a published package's public surface takes at least `minor`, and my own Clause-② derivation above is the argument for it: a new callable member on an already-exported class is exactly that widening. Regraded to `minor`, and the entry now names the added API so an upgrading consumer reads what is new. `node scripts/check-changeset-no-major.mjs --base origin/main` driven offline against this PR's own event payload: "✓ LEVEL AXIS: this PR declares clause-② `yes`, and no package whose `packages/**/src/**` it moves is graded `patch`", exit 0. The lockstep cost is real and named rather than dodged: every publishable package is in the `fixed` group, so this minor moves the group's minor — that is the price the ruling sets for the widening, not a reason to under-grade it. **2. `Lint & Repo Gates` / `Slot-lookup ratchet` — a real finding about this diff.** The ratchet is right and my tests were wrong: both plugin tests this PR added wrote `const messaging: any = ctx.getService('messaging')`, which is the erasure shape `slot-lookup/no-any-assignment` bans, and `messaging-service-plugin.test.ts` is grandfathered for its EXISTING sites only — so the file's count grew 2 to 4. Fixed at the source with the spelling this same file already uses for its other lookups (`as MessagingService`). ⛔ The two grandfathered sites were left alone and `scripts/slot-lookup-baseline.json` is untouched: sweeping them is the batch work that owns that ledger, and re-baselining to clear my own red would be the one move this gate exists to prevent. Verdict line after the fix: "✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new… baseline key set verified against e592b4b: no files added", exit 0 (captured before any pipe). ⚠️ Worth recording against my own gate report: this red came out of the 49 I had left to CI. The reading was contract-legal, but a diff that adds test code doing service lookups owes `check:slot-lookup` locally, and I did not run it. **Re-verified at `6166e303c`**, after both remedies: closure build, `pnpm --filter @objectstack/service-messaging test` -> `Test Files 44 passed (44) · Tests 467 passed (467)`, and `typecheck` — one chained run, VERDICT command-exit 0. Both ablations re-run at this head because the ratchet fix touched the very file M1's red is read from: M1 still `Tests 2 failed | 7 passed` ("expected [ 'inbox' ] to include 'sms'"), M2 still `Tests 1 failed | 4 passed`, each restored to its HEAD blob with `git diff HEAD` empty. Authored by Claude Code, session `session_01URLHobLUJB9K1ABV6ofdjj`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 842fa04 commit e07eecf

5 files changed

Lines changed: 521 additions & 28 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-messaging': minor
3+
---
4+
5+
Mount the email and SMS channels per lookup instead of deciding once at `kernel:ready`
6+
7+
The messaging plugin registered its email and SMS channels behind `if (getEmail())` /
8+
`if (getSms())` inside a `kernel:ready` hook. That guard ran exactly once, so a transport
9+
service that registered later in the same boot — from a plugin ordered after this one, from
10+
`kernel:bootstrapped` / `kernel:listening`, or at runtime — never got its channel, and every
11+
`notify` naming that channel was refused as "not registered" for the life of the process.
12+
13+
New public surface (which is why this grades `minor` and not `patch`, per the 2026-09-04 ruling
14+
that a purely additive widening of a published surface takes at least a minor):
15+
`MessagingService.registerChannelProvider(id, resolve)` mounts a channel that is resolved on
16+
every lookup, and the plugin now mounts both channels through it: the mount tracks the
17+
transport instead of recording a verdict about it, and the dispatcher — which has always
18+
looked channels up dynamically — picks up a late transport without a restart. A composition
19+
that never registers the transport is unchanged: the channel is not mounted, fan-out refuses
20+
it, no delivery row is written, and nothing is recorded in
21+
`sys_notification.suppressed_channels`.
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#18050] A channel MOUNT is resolved per lookup — it is not a verdict
4+
// recorded once while the service registry was still filling.
5+
//
6+
// ## What was wrong
7+
//
8+
// `messaging-service-plugin.ts` registered the email and SMS channels inside a
9+
// `kernel:ready` hook, behind `if (getEmail())` / `if (getSms())`. The comment
10+
// above that guard reasoned "the dispatcher looks channels up dynamically, so
11+
// registering after it is fine" — true of the dispatcher, and contradicted by
12+
// the guard beneath it: the `if` ran exactly ONCE and nothing revisited it. A
13+
// transport that registered a moment later — a plugin ordered after this one
14+
// registering from its own `kernel:ready` handler, `kernel:bootstrapped`,
15+
// `kernel:listening`, or any runtime mount — never got its channel, and every
16+
// `notify` naming it was refused as "not registered" for the life of the
17+
// process. That is the three-part shape AGENTS.md's "Startup registry reads"
18+
// section names, and `registerChannelProvider` is its first cure applied here:
19+
// resolve where it is USED, not where you start.
20+
//
21+
// ## Why the facts below are pinned on ONE pass
22+
//
23+
// Each alone is satisfied by an implementation broken in a different direction:
24+
//
25+
// * "the late transport delivered" — also true of an implementation that
26+
// mounts every named channel unconditionally, which would turn a typo into
27+
// a silently no-opping channel.
28+
// * "the absent transport was refused" — also true of the OLD code, which is
29+
// the state this file exists to leave behind.
30+
// * "the rows reached the dispatcher" — also true of a fan-out that wrote
31+
// rows the dispatcher could only dead-letter, i.e. the #18050 defect the
32+
// durable path already fixed.
33+
//
34+
// Only the conjunction — one service, one outbox, one dispatcher tick, the
35+
// transport arriving BETWEEN two emits — says the mount tracks the transport
36+
// rather than a snapshot of it.
37+
//
38+
// ## The boundary this must not move
39+
//
40+
// ⛔ An unmounted channel is still REFUSED, never suppressed. Whether a MOUNTED
41+
// channel can send for a tenant is `isAvailable`'s question and lands in
42+
// `sys_notification.suppressed_channels`; whether a channel is mounted at all
43+
// is a COMPOSITION fact and lands in a failed `DeliveryOutcome` with no row and
44+
// no column (#18041's settlement, pinned from the other side in
45+
// `unregistered-channel.test.ts` and `channel-availability.test.ts`). The
46+
// second and third tests below hold that line while the mount moves.
47+
48+
import { describe, it, expect } from 'vitest';
49+
import { MessagingService } from './messaging-service.js';
50+
import { MemoryNotificationOutbox } from './memory-outbox.js';
51+
import { NotificationDispatcher } from './dispatcher.js';
52+
import type { Delivery, MessagingChannel } from './channel.js';
53+
54+
function silentLogger() {
55+
return { info: () => {}, warn: () => {}, error: () => {} };
56+
}
57+
58+
/** A data engine double that captures the `sys_notification` insert — see `unregistered-channel.test.ts`. */
59+
function capturingEngine() {
60+
const inserts: Array<{ object: string; row: Record<string, unknown> }> = [];
61+
return {
62+
inserts,
63+
engine: {
64+
async insert(object: string, row: Record<string, unknown>) {
65+
inserts.push({ object, row });
66+
return { id: `evt_${inserts.length}`, ...row };
67+
},
68+
async find() {
69+
return [];
70+
},
71+
} as never,
72+
};
73+
}
74+
75+
function channelDouble(id: string): { channel: MessagingChannel; sent: Delivery[] } {
76+
const sent: Delivery[] = [];
77+
return {
78+
sent,
79+
channel: {
80+
id,
81+
async send(_ctx, delivery) {
82+
sent.push(delivery);
83+
return { ok: true };
84+
},
85+
},
86+
};
87+
}
88+
89+
const EMIT = {
90+
topic: 'deal.won',
91+
audience: ['user_1', 'user_2'],
92+
channels: ['inbox', 'email'],
93+
organizationId: 'org_1',
94+
payload: { title: 'Deal closed' },
95+
};
96+
97+
describe('a channel mounted through a provider (#18050)', () => {
98+
it('mounts the moment its transport appears — mid-process, between two emits — and the dispatcher DELIVERS those rows', async () => {
99+
const outbox = new MemoryNotificationOutbox(1);
100+
const data = capturingEngine();
101+
const service = new MessagingService({
102+
logger: silentLogger(),
103+
outbox,
104+
getData: () => data.engine,
105+
});
106+
service.registerChannel(channelDouble('inbox').channel);
107+
108+
// The transport this composition has not registered YET — the plugin's
109+
// `getEmail()` in miniature, and the only moving part in this test.
110+
let transport: MessagingChannel | undefined;
111+
service.registerChannelProvider('email', () => transport);
112+
113+
// (1) While it is absent the answer is the one a composition that never
114+
// registers it has always produced: refused, no row written, so
115+
// there is nothing for the dispatcher to dead-letter.
116+
const before = await service.emit(EMIT);
117+
expect((await outbox.list()).filter((r) => r.channel === 'email')).toHaveLength(0);
118+
expect(
119+
before.deliveries
120+
.filter((d) => d.channel === 'email')
121+
.map((d) => ({ recipient: d.recipient, ok: d.ok, error: d.error }))
122+
.sort((a, b) => a.recipient.localeCompare(b.recipient)),
123+
).toEqual([
124+
{ recipient: 'user_1', ok: false, error: "channel 'email' not registered" },
125+
{ recipient: 'user_2', ok: false, error: "channel 'email' not registered" },
126+
]);
127+
128+
// (2) The transport registers LATER than `kernel:ready`. Under the old
129+
// one-shot guard this moment was unreachable: the mount decision had
130+
// already been taken and recorded as a non-registration.
131+
const email = channelDouble('email');
132+
transport = email.channel;
133+
134+
const after = await service.emit(EMIT);
135+
expect(after.deliveries.filter((d) => d.channel === 'email').every((d) => d.ok)).toBe(true);
136+
expect(
137+
(await outbox.list()).filter((r) => r.channel === 'email').map((r) => r.recipientId).sort(),
138+
).toEqual(['user_1', 'user_2']);
139+
140+
// (3) …and those rows are DELIVERED, not dead-lettered on attempt one.
141+
// The dispatcher resolves through the SAME lookup this service
142+
// answers (`ChannelRegistry.getChannel`), so the old guard's claim —
143+
// "the dispatcher looks channels up dynamically" — is finally true
144+
// end to end rather than contradicted by the code beneath it.
145+
const dispatcher = new NotificationDispatcher({
146+
nodeId: 'node-test',
147+
outbox,
148+
channels: service,
149+
channelContext: { logger: silentLogger() },
150+
intervalMs: 10_000,
151+
});
152+
await dispatcher.tick();
153+
154+
const settled = (await outbox.list()).filter((r) => r.channel === 'email');
155+
expect(settled.map((r) => r.status)).toEqual(['success', 'success']);
156+
expect(settled.map((r) => r.attempts)).toEqual([1, 1]);
157+
expect(email.sent.map((d) => d.recipient).sort()).toEqual(['user_1', 'user_2']);
158+
});
159+
160+
it('unmounts again when the transport goes away — the same refusal, not a new failure mode', async () => {
161+
// Symmetry is the point: a provider is a question asked every time, so
162+
// the answer has to be allowed to change back. An implementation that
163+
// memoised the PRESENCE (rather than the channel object) would pass the
164+
// test above and fail this one, having simply moved the one-shot verdict
165+
// to a later moment.
166+
const outbox = new MemoryNotificationOutbox(1);
167+
const data = capturingEngine();
168+
const service = new MessagingService({ logger: silentLogger(), outbox, getData: () => data.engine });
169+
service.registerChannel(channelDouble('inbox').channel);
170+
171+
const email = channelDouble('email');
172+
let transport: MessagingChannel | undefined = email.channel;
173+
service.registerChannelProvider('email', () => transport);
174+
175+
expect(service.getRegisteredChannels()).toContain('email');
176+
expect(service.getChannel('email')).toBe(email.channel);
177+
178+
transport = undefined;
179+
180+
expect(service.getRegisteredChannels()).not.toContain('email');
181+
expect(service.getChannel('email')).toBeUndefined();
182+
183+
const result = await service.emit(EMIT);
184+
expect((await outbox.list()).filter((r) => r.channel === 'email')).toHaveLength(0);
185+
expect(result.deliveries.filter((d) => d.channel === 'email').map((d) => d.error)).toEqual([
186+
"channel 'email' not registered",
187+
"channel 'email' not registered",
188+
]);
189+
});
190+
191+
it('⛔ records NO suppression while unmounted — the event row keeps the column set it had', async () => {
192+
// #18041's settlement, held while the mount became dynamic: an absent
193+
// mount is a COMPOSITION fact, identical for every tenant in the
194+
// process, so it must not enter a per-tenant availability column. A
195+
// stack whose `sys_notification` predates that column would answer
196+
// INVALID_FIELD and lose the notification outright.
197+
const outbox = new MemoryNotificationOutbox(1);
198+
const data = capturingEngine();
199+
const service = new MessagingService({ logger: silentLogger(), outbox, getData: () => data.engine });
200+
service.registerChannel(channelDouble('inbox').channel);
201+
service.registerChannelProvider('email', () => undefined);
202+
203+
const result = await service.emit({ ...EMIT, audience: ['user_1'] });
204+
205+
expect(result.suppressed).toEqual([]);
206+
expect(data.inserts).toHaveLength(1);
207+
expect(Object.prototype.hasOwnProperty.call(data.inserts[0].row, 'suppressed_channels')).toBe(false);
208+
});
209+
210+
it('treats a provider that THROWS as not mounted, says so once, and keeps asking', async () => {
211+
// A resolver reaching a service registry can throw (`getService` does,
212+
// for an unregistered name). Fail-closed on the mount, fail-quiet on the
213+
// log — and ⛔ never record the failure as a verdict: the very next
214+
// lookup asks again, which is how a transport that recovers gets its
215+
// channel back without a restart.
216+
const warnings: string[] = [];
217+
const service = new MessagingService({
218+
logger: { ...silentLogger(), warn: (...a: unknown[]) => warnings.push(String(a[0])) },
219+
getData: () => undefined,
220+
});
221+
const email = channelDouble('email');
222+
let broken = true;
223+
service.registerChannelProvider('email', () => {
224+
if (broken) throw new Error('email service exploded');
225+
return email.channel;
226+
});
227+
228+
expect(service.getChannel('email')).toBeUndefined();
229+
expect(service.getChannel('email')).toBeUndefined();
230+
const lines = warnings.filter((w) => w.includes("channel provider 'email' threw"));
231+
expect(lines).toHaveLength(1);
232+
expect(lines[0]).toContain('email service exploded');
233+
234+
broken = false;
235+
expect(service.getChannel('email')).toBe(email.channel);
236+
});
237+
238+
it('a directly registered channel and a provider replace each other under one id', async () => {
239+
// Both registries answer the same `getChannel`, so an id can only mean
240+
// one thing at a time — otherwise a stale direct registration would
241+
// shadow the provider that replaced it, which is the one-shot verdict
242+
// again wearing a different hat.
243+
const warnings: string[] = [];
244+
const service = new MessagingService({
245+
logger: { ...silentLogger(), warn: (...a: unknown[]) => warnings.push(String(a[0])) },
246+
getData: () => undefined,
247+
});
248+
const direct = channelDouble('email');
249+
const lazy = channelDouble('email');
250+
251+
service.registerChannel(direct.channel);
252+
service.registerChannelProvider('email', () => lazy.channel);
253+
expect(service.getChannel('email')).toBe(lazy.channel);
254+
expect(service.getRegisteredChannels().filter((id) => id === 'email')).toHaveLength(1);
255+
256+
service.registerChannel(direct.channel);
257+
expect(service.getChannel('email')).toBe(direct.channel);
258+
259+
service.unregisterChannel('email');
260+
expect(service.getChannel('email')).toBeUndefined();
261+
expect(warnings.filter((w) => w.includes("already registered; replacing"))).toHaveLength(2);
262+
});
263+
});

packages/services/service-messaging/src/messaging-service-plugin.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ function provisionCtx() {
121121
['manifest', { register() {} }],
122122
]);
123123
const readyHooks: Array<() => Promise<void> | void> = [];
124-
const logger = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; } };
124+
const logs: string[] = [];
125+
const logger = { info(msg: string) { logs.push(String(msg)); }, warn() {}, error() {}, debug() {}, child() { return logger; } };
125126
const ctx = {
126127
logger,
127128
getService(name: string) { return services.get(name); },
@@ -130,7 +131,7 @@ function provisionCtx() {
130131
if (event === 'kernel:ready') readyHooks.push(fn);
131132
},
132133
} as any;
133-
return { ctx, engine, synced, fireReady: async () => { for (const fn of readyHooks) await fn(); } };
134+
return { ctx, engine, synced, logs, fireReady: async () => { for (const fn of readyHooks) await fn(); } };
134135
}
135136

136137
describe('MessagingServicePlugin — email/sms channel registration (kernel:ready)', () => {
@@ -152,6 +153,47 @@ describe('MessagingServicePlugin — email/sms channel registration (kernel:read
152153
const messaging: any = ctx.getService('messaging');
153154
expect(messaging.getRegisteredChannels()).not.toContain('sms');
154155
});
156+
157+
// [#18050] The two pins above say what the mount answers at `kernel:ready`.
158+
// These say it is an ANSWER and not a VERDICT: the plugin reads the service
159+
// registry on every lookup, so a transport that registers after
160+
// `kernel:ready` — another plugin's own ready handler, `kernel:bootstrapped`,
161+
// `kernel:listening`, a runtime mount — still gets its channel. Before this,
162+
// `if (getSms())` ran once and recorded the absence as a permanent
163+
// non-registration, and every `notify(channels:['sms'])` for the life of the
164+
// process was refused as "not registered" with the transport sitting right
165+
// there in the registry.
166+
it('mounts the sms channel when the sms service arrives AFTER kernel:ready (#18050)', async () => {
167+
const { ctx, fireReady } = provisionCtx();
168+
await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx);
169+
await fireReady();
170+
const messaging = ctx.getService('messaging') as MessagingService;
171+
expect(messaging.getRegisteredChannels()).not.toContain('sms');
172+
173+
ctx.registerService('sms', { async send() { return { status: 'sent' }; } });
174+
175+
expect(messaging.getRegisteredChannels()).toContain('sms');
176+
expect(messaging.getChannel('sms')?.id).toBe('sms');
177+
});
178+
179+
it('mounts the email channel when the email service arrives AFTER kernel:ready, and builds it ONCE (#18050)', async () => {
180+
const { ctx, fireReady, logs } = provisionCtx();
181+
await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx);
182+
await fireReady();
183+
const messaging = ctx.getService('messaging') as MessagingService;
184+
expect(messaging.getRegisteredChannels()).not.toContain('email');
185+
186+
ctx.registerService('email', { async send() { return { id: 'mail_1' }; } });
187+
188+
const first = messaging.getChannel('email');
189+
expect(first?.id).toBe('email');
190+
// The mount is re-decided per lookup; the channel OBJECT is not rebuilt
191+
// per lookup, so it keeps its identity (and its template-store handle)
192+
// and announces the bind exactly once however often it is consulted.
193+
expect(messaging.getChannel('email')).toBe(first);
194+
expect(messaging.getRegisteredChannels()).toContain('email');
195+
expect(logs.filter((l) => l.includes('email channel registered'))).toHaveLength(1);
196+
});
155197
});
156198

157199
describe('MessagingServicePlugin — system table provisioning', () => {

0 commit comments

Comments
 (0)