Skip to content

Commit 7010085

Browse files
claude[bot]claude
andauthored
fix(service-messaging): the durable fan-out refuses an unregistered channel instead of writing a delivery row that can only dead-letter (#18081)
Part of #18050 Clause-②: no Re-derived from the delivered diff, with the control, below. ⚠️ **Read this first: the card has two halves and this PR lands ONE of them.** The dispatch asked for a closing keyword; the body deliberately does not carry one, because fork 2 is answered but not implemented. The PM should close the card by hand once it has read the fork-2 measurement below, or re-dispatch that half. Everything in the "What was NOT done" section is a measurement, not a shortfall of effort. ## What was wrong `MessagingService.emit()` has two fan-out paths. The inline P0 path (`fanOut`) has always refused a channel nobody registered: no `send()`, one failed `DeliveryOutcome` per `(recipient × channel)`, `error: channel '(id)' not registered`. The durable P1 path (`enqueueDeliveries`) had **no registration check at all** — its two loops contained no `channels.get`, no `channels.has`, no refusal — so it wrote one `sys_notification_delivery` row per recipient, and `NotificationDispatcher` dead-lettered every one of them on attempt **one** (`processRow` and `processDigestGroup` both ack `dead: true` the moment `getChannel()` answers nothing). That is #17732's reported symptom: a row that exists only to die. Ruling `5644350987` structurally cannot reach it — `isAvailable()` is a member of a channel IMPLEMENTATION, and an unregistered channel has none to ask. ## What this PR does — fork 1, the "refuse" side `enqueueDeliveries` now checks registration before it enqueues. An unregistered channel gets **no row** and the caller gets the **same failed `DeliveryOutcome` the inline path already produced**, so "nothing was sent and here is why" reads identically whichever path served the emit. `EmitResult.failed` counts them; `enqueued` and `delivered` do not, so no summary can claim work that never existed. The refusal is logged **once per channel per emit**, carrying the number of rows it refused and the remedy — not once per recipient. The durable path is the high-volume one: a 500-recipient audience on one missing channel must not print 500 identical lines, and the count is what sizes the misconfiguration. ### ⛔ Why it is NOT recorded in `sys_notification.suppressed_channels` `CHANNEL_UNAVAILABLE_REASONS` is documented on the seam as *"Why a channel is not available **for a tenant**"* — every value is a column value an operator filters and reports on. An unregistered channel is a **composition** fact: identical for every tenant in the process, and fixed by mounting the channel, not by configuring the tenant. Folding it in would make a per-tenant report assert a deployment-wide misconfiguration. It is also the fence #18041 settled, from the other side. Recording it would need a new literal in that closed set, which `channel-availability.test.ts` holds byte-equal to the copy inlined on `sys-notification.object.ts` — i.e. an edit in `packages/platform-objects`, which is `domain:engine` and not this card's. Both reasons point the same way, and the suppression key stays written **only when something was actually suppressed**, so the common path's column set is unchanged (pinned again here, by enumeration). ## What was NOT done — fork 2, and why. Two measurements. **1. The card's stated mechanism for fork 2 is falsified.** The card reads `messaging-service-plugin.ts:261-268` as AGENTS.md's "Startup registry reads" three-part shape, on the grounds that *"an email service that registers later never gets its channel"*. Measured on this tree: - `ObjectKernel.use()` throws `Cannot register plugins after bootstrap has started` whenever `state !== 'idle'` (`packages/core/src/kernel.ts:240-243`), so no plugin joins the composition after boot begins. - Both providers register their service in `init()`, not later: `plugin-email/src/email-plugin.ts:430` and `service-sms/src/sms-plugin.ts:214`. - `kernel-base.ts:291` states the contract directly: `kernel:ready` is *"the only correct moment for a plugin to assert that the preconditions it declared were actually met (the registries are still filling during `init()`)"* — i.e. the service registry is no longer filling at `kernel:ready`. ⇒ Part 1 of the three-part shape ("a read of a registry that is still filling") does not hold at that call site, so the rule does not reach it. What *is* live is narrower and does not need a registry argument: a deployment with no email plugin simply has no `email` channel, and its flows notifying on `['inbox','email']` take the path this PR just fixed. **2. The obvious cure for fork 2 is unsafe until a parked defect is fixed first.** Cure 1 in that section ("resolve where it is used") maps cleanly here: register the email channel unconditionally and let `isAvailable()` — which already reads `opts.getEmail()` live at `email-channel.ts:238` — answer `transport_not_configured`. That would convert this whole class into ruling A's suppression shape, with the audit trail, and is clearly the better end state. But it also **widens the reachability of the defect the card parked**: - `email-channel.ts:252` returns `{ ok: true }` when no email service is present (*"capability not installed — no-op"*), and `sms-channel.ts:151` does the same. The SMS channel has no `isAvailable` at all. - Today, with no email service, the channel is absent ⇒ the dispatcher dead-letters the row: wrong, but **loud**. - Register it unconditionally and the dispatcher instead calls `send()` ⇒ `{ ok: true }` ⇒ the delivery row is acked **sent, with nothing sent**. That is a durability lie and a strictly worse shape than the one being fixed. ⇒ Fork 2 needs `send()` to stop reporting success for an absent transport first — which is a retry-semantics change (`classifyError`, permanent vs retryable) with its own pins, and the card explicitly parked it. Routing that is the PM's. ⛔ No unconditional registration in this PR. ## Clause-② — re-derived from the DELIVERED diff, with a discriminating control The delivered diff adds **no published declaration and no accepted value**. Only one changed file ships at all (`files: ["dist","README.md","CHANGELOG.md"]`; the two test files and the changeset never leave the repo). In that file, every added non-comment line lives inside the body of a **private** method, and TypeScript emits private members with no signature: ``` packages/services/service-messaging/dist/index.d.ts:2048 private enqueueDeliveries; ``` **The probe, and the control that proves the probe can see a real export** — run against the freshly built `dist/index.d.ts`: ``` CONTROL (known-published exports, must be non-zero) CHANNEL_UNAVAILABLE_REASONS 5 hits ChannelSuppression 3 hits DeliveryOutcome 5 hits EmitResult 3 hits MessagingChannel 17 hits PROBE (identifiers this diff adds) unregistered-channel 0 hits (d.ts) 0 hits (index.js) channel_not_registered 0 hits (d.ts) 0 hits (index.js) ``` The word `refused` does appear 10× in the published `.d.ts` — 9 of them predate this branch and the 10th is the new doc comment; none is a declaration (`grep -E '^\s*(export|declare|type|interface|const|function|class).*refused'` is empty). And the negative is about the **accept set**, not about reach: the new behaviour does ship (`dist/index.js` carries the new warn text, 1 hit), which is what makes the zeros above informative rather than a probe that simply cannot see anything. ⇒ `Clause-②: no`. No member was added to a published interface, no value was added to a published enum, no key was added to a published payload. A `patch` changeset is therefore the correct grade, not the `minor` a `yes` would require. ## Recovery round — the `Test Core (6/6)` red, its root cause, and the fix The previous push left `Test Core (6/6)` failing. The shard log shows `check-test-completeness: @objectstack/plugin-auth was scheduled but never reached` and the same for `@objectstack/downstream-contract` — 1 of 3 scheduled packages reported, 2 never reached. ⚠️ That "never reached" line is a CONSEQUENCE, not the cause. The shard runs turbo, turbo stops on the first failing task, and the two packages behind the failing one never got to run. ⛔ It is not a flake and nothing was skipped, disabled or quarantined to clear it. **Root cause — a cross-package pin this branch's own producer change falsified.** ``` FAIL packages/services/service-automation/src/builtin/notify-delivery-outcome.integration.test.ts > notify run summary vs. the durable delivery record (#7747) > does not report a countable act for a delivery that dead-letters on an unregistered channel AssertionError: expected [] to have a length of 1 but got +0 (line 122) ``` That file is on `origin/main` (landed by #7875 for card #7747) and this branch never touched it. Its first case boots WITHOUT `push` registered and asserted, as its scenario setup, that the durable fan-out wrote one delivery row and the dispatcher dead-lettered it: ```ts expect(rows).toHaveLength(1); expect(rows[0].channel).toBe('push'); expect(rows[0].status).toBe('dead'); ``` Those three lines describe **exactly the defect #18050 filed**. This PR removes the row, so the pin that recorded the row had to move with the producer. **Why the inherited push did not see it.** The producer package's own suite is green (460/460) and always was — the contradicting pin lives in a CONSUMER package, and `service-automation` resolves `@objectstack/service-messaging` through `dist/` (its vitest aliases only `@objectstack/platform-objects`), so nothing in the producer's own lane could surface it. **The fix — re-pinned, ⛔ not relaxed.** `notify-delivery-outcome.integration.test.ts` now asserts the new producer contract: | | before | after | |---|---|---| | outbox after `dispatcher.tick()` | 1 row, `status: 'dead'` | **0 rows** | | run summary | `acted: 0, unmeasured: 1` | `selected: 1, acted: 0, unmeasured: 0` | | the #7747 invariant `acted <= non-dead rows` | bound was 0-non-dead | bound is now **0 rows at all** | The `unmeasured` 1 -> 0 move is the point, not a relaxation. `unmeasuredEffect` means "the count is unknown because the dispatcher decides later"; since this PR there is no later — the refusal is synchronous, so the count is KNOWN and it is zero. That is the reading `notify-node.ts` states in its own words: > ⛔ The fix is NOT to report the zero as `unmeasuredEffect`. That flag means > "the count is unknown", and this count is known and it is zero; claiming > otherwise would take the run OUT of the broken-sweep filter > (`selected > 0 AND acted = 0 AND unmeasured = 0`) — the platform's own alarm > for a green-but-inert sweep — on precisely the run that should be inside it. So the durable path now lands where the inline path already was: the fourth case in that same file asserts this identical triple and calls it "correctly eligible for the broken-sweep alert". Making the two fan-out paths agree is what this card set out to do, and the consumer pin is where that agreement becomes observable. The tick is deliberately kept in the updated case: it proves nothing APPEARS later either, which is strictly stronger than the old "a row exists and is dead". ## Independent judgement of the inherited work The inherited diff was re-read and judged rather than extended: - **The `refuse` side of fork 1 is right, and is kept.** The registration check mirrors the inline path's existing one, the failed `DeliveryOutcome` is the same shape, and `notify-node.ts`'s own stated design is what makes the resulting `acted: 0, unmeasured: 0` the correct answer rather than a loss of signal. ⛔ Nothing about the `suppressed_channels` reasoning was reversed. - **One real gap, now closed:** the behaviour change has cross-package consumers and only the producer package was run. The consumer set was enumerated from the manifests (`cli`, `dogfood`, `example-showcase`, `plugin-auth`, `plugin-webhooks`, `runtime`, `service-automation`) and the affected ones were run — see Evidence. - **`scripts/engine-double-contract.pinned.json` is NOT in this diff** (0 hits over the whole branch range), so this card does not join the live serial relay. `check:engine-double-contract` exits 0. ## Evidence — this round, measured on `a3950e4fa` ⚠️ Every reading below was taken on a BUILT closure. `service-automation` consumes `service-messaging` through `dist/`, so a run on an unbuilt tree measures nothing. Closure build: `pnpm --filter '@objectstack/service-automation^...' build` :: `VERDICT command-exit 0`. **Reproduction, then the fix** ``` before vitest run src/builtin/notify-delivery-outcome.integration.test.ts Test Files 1 failed (1) · Tests 1 failed | 3 passed (4) AssertionError: expected [] to have a length of 1 but got +0 after Test Files 1 passed (1) · Tests 4 passed (4) ``` **Affected packages, full suites** — `VERDICT command-exit 0` | package | files | tests | |---|--:|--:| | `@objectstack/service-automation` | 134 | **1581 passed** (was 1580 passed / 1 failed) | | `@objectstack/service-messaging` | 43 | 460 passed | **The two packages the aborted shard never reached** — run here because a shard that stops early cannot be read as "the rest passed". `VERDICT command-exit 0`: | package | files | tests | |---|--:|--:| | `@objectstack/plugin-auth` (also a direct consumer) | 110 | 2340 passed | | `@objectstack/downstream-contract` | 3 | 31 passed | **Typecheck** — `VERDICT command-exit 0` for both affected packages. `check:test-typecheck` reports service-automation's test layer compiles under `tsconfig.test.json`, 0 files / 0 errors, and `tsc -p tsconfig.test.json --listFiles` puts the edited file in the program (1 hit) — so the green is attributable, not the "typecheck excludes `*.test.ts`" false reading. **Reverse verification (ablation)** — direction predicted **RED**, observed **RED**, and it fails on the NEW assertion, so the updated pin is discriminating rather than vacuous. The guard block was deleted from the committed producer source, the producer was REBUILT (the consumer reads `dist/`), and the mutation was proven to have reached the artifact before the colour was read: ``` anchor before mutation grep -c 'if (!this.channels.has(channel))' = 1 after mutation = 0 on-disk blob 0b03bae9d (HEAD blob 27c346e) preflight --absent ✓ marker absent from all 6 built files ABLATED RUN Tests 1 failed | 3 passed (4) AssertionError: expected [ { …(18) } ] to have a length of +0 but got 1 ^ line 144: expect(rows).toHaveLength(0) <- the NEW assertion restore on-disk blob 27c346e == HEAD blob; git diff HEAD empty preflight (present) ✓ marker present in 2 built files ✓ tree: working tree clean against HEAD RESTORED RUN Tests 4 passed (4) ``` The script installed a `trap` on EXIT, INT and TERM calling a restore function, restore is proven by STATE (blob equality plus a whole-tree `git status --porcelain`), never by an exit code. **Gates — four numbers, reconciled against `scripts/pm/dispatch-gates.mjs --commands`** Derived on this tree with `--repo objectstack-ai/objectstack` asserted: | | | |---|---| | derived | **62** | | run | **13** | | NOT MEASURED | **0** | | UNRUN | **49** | ⛔ The 49 are UNRUN, **not passed**. They are the `Lint & Repo Gates` farm, which the standing os-dev contract reserves for CI rather than enumerating locally. The 13 run here are the families this diff actually implicates, each read from the gate's own verdict line with the exit code captured before any pipe: ``` exit=0 check:nul-bytes exit=0 check-closing-keyword-parity (+ --self-test) exit=0 check:engine-double-contract exit=0 check-empty-changeset --base origin/main exit=0 check:test-source-alias exit=0 check-adr-0087-registration --base origin/main exit=0 check:cross-package-test-inputs exit=0 check-changeset-no-major --base origin/main ``` plus the two affected packages' `test` and `typecheck` tasks counted above. A control-character self-scan over all five files in the branch range (`grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'`) matched nothing. ⚠️ **NOT MEASURED, stated as such:** `dispatch-gates.mjs` reports this tree is 2 commits behind `origin/main` (`57343f761`), with `scripts/pm/check-skill-line-ratchet.mjs` changed in that range. This diff touches no `skills/**`, so no family is added by it, but the derivation above is a reading about `a3950e4fa` and not about the merge the queue will build. ## Acceptance notes — observed, not filed, not fixed here - `fanOut` logs its "not registered" warn once per `(recipient × channel)` pair rather than once per channel per emit. Same noise shape this PR avoided on the durable path; the inline path is the low-volume one, so it is left alone rather than widened into scope. - `email-channel.ts:252` / `sms-channel.ts:151` returning `{ ok: true }` for an absent transport is already recorded on #18050's body. It is not filed separately here so that the card's own record stays the single home for it — which is the second reason this PR does not carry a closing keyword. - `sms-channel.ts` implements no `isAvailable`, so the SMS half of ruling A is unrealised. In-lane, but it only becomes useful together with fork 2, so it belongs to that routing. - The consumer pin this round repaired lives in `service-automation`, not in this card's landing package. It is in-lane (`domain:services`) and was owed by this diff's own behaviour change, so it is fixed here rather than filed. - `sms-channel.ts` still implements no `isAvailable`, and `email-channel.ts:252` / `sms-channel.ts:151` still answer `{ ok: true }` for an absent transport. Both are already recorded on #18050's body; noted, not filed, so the card stays the single home for them. - This branch is 2 commits behind `origin/main`. No merge was taken in this round: the queue rebuilds the PR as merged and re-runs the required contexts on that generation, which is the reading that decides. Authored by Claude Code in session `session_01URLHobLUJB9K1ABV6ofdjj` (recovery round; the first round was lost to a container restart). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d438b3a commit 7010085

5 files changed

Lines changed: 391 additions & 29 deletions

File tree

‎.changeset/great-jars-sleep.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
---
4+
5+
fix(service-messaging): the durable fan-out refuses a channel nobody registered instead of writing a delivery row for it
6+
7+
`MessagingService.emit()` on the reliable-delivery (outbox) path wrote one
8+
`sys_notification_delivery` row per recipient for a channel the composition had
9+
never registered, and the dispatcher dead-lettered every one of them on attempt
10+
one. The inline path had always refused this case; only the durable path wrote
11+
the rows, so a deployment whose flows notify on `['inbox','email']` without an
12+
email plugin accumulated guaranteed-dead rows in the hot delivery table.
13+
14+
The durable path now reports the same failed delivery outcome the inline path
15+
reports — `ok: false`, `error: "channel '<id>' not registered"`, counted in
16+
`EmitResult.failed` — and writes no row. The refusal is logged once per channel
17+
per emit with the number of rows it refused, not once per recipient.
18+
19+
The refusal is deliberately **not** recorded in
20+
`sys_notification.suppressed_channels`: that key answers "why can this tenant not
21+
send on this channel", and an unregistered channel is a composition fact,
22+
identical for every tenant in the process. The event row's column set is
23+
unchanged.

‎packages/services/service-automation/src/builtin/notify-delivery-outcome.integration.test.ts‎

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,24 @@ import { registerNotifyNode } from './notify-node.js';
2828
* was called: the finding is precisely that those two records contradict each
2929
* other, so an internal call-count assertion would pass while the defect stands.
3030
*
31-
* On `origin/main` the first test fails with `acted: 1` — the notify node
32-
* counts `EmitResult.delivered`, which in outbox mode is an ENQUEUED count.
31+
* At the time #7747 landed, the first test failed with `acted: 1` — the notify
32+
* node counted `EmitResult.delivered`, which in outbox mode is an ENQUEUED count.
33+
*
34+
* ## Amended by #18050 — the first case's durable record is now EMPTY
35+
*
36+
* #7747's repro boots without `push` registered, and back then the durable
37+
* fan-out enqueued a row for it anyway that the dispatcher could only
38+
* dead-letter. #18050 fixed that at the producer: `enqueueDeliveries` refuses an
39+
* unregistered channel before it writes, reporting the same failed
40+
* `DeliveryOutcome` the inline path always did. So the first test's scenario
41+
* moved buckets — from "an effect I cannot count YET" (`unmeasured: 1`, the
42+
* dispatcher decides later) to "an effect I have counted and it is zero"
43+
* (`acted: 0, unmeasured: 0`, refused synchronously).
44+
*
45+
* ⛔ That is not this file's invariant weakening. #7747's invariant is "the
46+
* summary must not out-count what the durable record shows was delivered", and
47+
* it is asserted below against a bound that went from 0-non-dead-rows to
48+
* 0-rows-at-all. What changed is the producer, not what is demanded of it.
3349
*/
3450

3551
function silentLogger(): any {
@@ -107,37 +123,52 @@ function notifyFlow(channels: string[]) {
107123
}
108124

109125
describe('notify run summary vs. the durable delivery record (#7747)', () => {
110-
it('does not report a countable act for a delivery that dead-letters on an unregistered channel', async () => {
126+
it('reports a MEASURED zero — not a countable act — for an unregistered channel on the durable path', async () => {
111127
// 1) Boot without the `push` channel registered.
112128
const { outbox, dispatcher, engine } = bootOutboxStack([recordingChannel('inbox').channel]);
113129

114130
// 2) Fire a flow whose notify node targets ['push'].
115131
engine.registerFlow('nudge', notifyFlow(['push']));
116132
const run = await engine.execute('nudge');
117133

118-
// 3a) The durable record: the dispatcher dead-letters the row, because
119-
// no transport for `push` exists.
134+
// 3a) The durable record: NOTHING — and that is the #18050 change.
135+
// This assertion used to read `toHaveLength(1)` + `status: 'dead'`:
136+
// the durable fan-out enqueued a row for a channel with no transport
137+
// and the dispatcher dead-lettered it on attempt ONE. That row was
138+
// itself the defect #18050 fixed, so `enqueueDeliveries` now refuses
139+
// the channel up front and writes no row at all. The tick is kept
140+
// deliberately: it proves nothing APPEARS later either, which is a
141+
// strictly stronger statement than the old "a row exists and is dead".
120142
await dispatcher.tick();
121143
const rows = await outbox.list();
122-
expect(rows).toHaveLength(1);
123-
expect(rows[0].channel).toBe('push');
124-
expect(rows[0].status).toBe('dead');
125-
expect(rows[0].error).toContain("channel 'push' not registered");
144+
expect(rows).toHaveLength(0);
126145

127146
// 3b) The record an operator reads. The run still SUCCEEDS — the flow
128-
// did everything it can do synchronously, and failing it would make
129-
// a channel that registers a moment later retroactively break the
130-
// flow. What must not survive is the claim that it DELIVERED:
131-
// `acted` is the count the broken-sweep alert trusts, and the honest
132-
// answer at the moment the run settles is "an effect I cannot count
133-
// yet" — which the platform already spells `unmeasured`, and which
134-
// is not the same as `acted: 0` alone (that would claim the run did
135-
// nothing, and trip the alert on every healthy notify).
147+
// did everything it can do synchronously. What must not survive is
148+
// the claim that it DELIVERED.
149+
//
150+
// ⚠️ `unmeasured` moved 1 -> 0 here, and that is the POINT, not a
151+
// relaxation. `unmeasuredEffect` means "the count is unknown because
152+
// the dispatcher decides later". Since #18050 there is no later: the
153+
// refusal is synchronous, so the count is KNOWN and it is zero —
154+
// exactly the reading `notify-node.ts` demands ("this count is known
155+
// and it is zero; claiming otherwise would take the run OUT of the
156+
// broken-sweep filter ... on precisely the run that should be inside
157+
// it"). `selected: 1, acted: 0, unmeasured: 0` puts this run INSIDE
158+
// the `selected > 0 AND acted = 0 AND unmeasured = 0` alert, which is
159+
// where a notify that reached nobody and never will belongs.
160+
//
161+
// It is also what makes the two fan-out paths agree: the inline case
162+
// four tests down asserts this same triple and calls it "correctly
163+
// eligible for the broken-sweep alert". The durable path is not a
164+
// duplicate of it — it is the other side of the seam this file
165+
// exists for, and it is the side that used to disagree.
136166
expect(run.success).toBe(true);
137-
expect(run.summary).toMatchObject({ acted: 0, unmeasured: 1 });
167+
expect(run.summary).toMatchObject({ selected: 1, acted: 0, unmeasured: 0 });
138168

139-
// The finding itself, as one assertion: the summary must not out-count
140-
// what the durable record shows was actually delivered (here: nothing).
169+
// The #7747 finding itself, unchanged in force: the summary must not
170+
// out-count what the durable record shows was actually delivered. With
171+
// no row at all the bound is 0, so this is tighter than it was before.
141172
const notDead = rows.filter((r) => r.status !== 'dead').length;
142173
expect(run.summary!.acted).toBeLessThanOrEqual(notDead);
143174
});

‎packages/services/service-messaging/src/channel-availability.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,14 @@ describe('channel availability at fan-out (#17732)', () => {
339339

340340
it('leaves an UNREGISTERED channel on its existing path — ⛔ not folded into suppression', async () => {
341341
// Out of the ruling's scope on purpose: an unregistered channel has no
342-
// implementation to ask, so it keeps today's behaviour exactly. Pinned
343-
// so the boundary is deliberate rather than accidental.
342+
// implementation to ask, so it is not a SUPPRESSION. Pinned so the
343+
// boundary is deliberate rather than accidental.
344+
//
345+
// ⚠️ This is the INLINE path, and it is unchanged. #18050 later gave the
346+
// DURABLE path the same answer this one already gave — a failed
347+
// `DeliveryOutcome` and no row — so "keeps today's behaviour exactly" is
348+
// no longer true of the outbox half; `unregistered-channel.test.ts`
349+
// pins that half, including that it still records no suppression.
344350
const data = capturingEngine();
345351
const service = new MessagingService({ logger: silentLogger(), getData: () => data.engine });
346352
service.registerChannel(channelDouble('inbox').channel);

‎packages/services/service-messaging/src/messaging-service.ts‎

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -879,8 +879,9 @@ export class MessagingService {
879879
* The single notification ingress. Writes the L2 event, resolves the
880880
* audience, and fans the result out to its channels. An unregistered
881881
* channel, or a channel that throws, is reported as a failed delivery — it
882-
* never aborts the rest of the fan-out. A `dedupKey` that matches an
883-
* existing event short-circuits: the event id is returned and no new
882+
* never aborts the rest of the fan-out, and on the durable path it costs no
883+
* `sys_notification_delivery` row either (#18050). A `dedupKey` that matches
884+
* an existing event short-circuits: the event id is returned and no new
884885
* deliveries are produced.
885886
*
886887
* A channel that answers `isAvailable: { available: false }` for the tenant
@@ -1036,11 +1037,14 @@ export class MessagingService {
10361037
* default would mute every channel that has not been updated, which is a
10371038
* far worse failure than the workless rows this exists to stop.
10381039
* `channel-availability.test.ts` pins it from both sides.
1039-
* 2. **An UNREGISTERED channel is left alone.** It has no implementation to
1040-
* ask, so it keeps today's path exactly: the inline fan-out reports it as
1041-
* a failed delivery, the outbox enqueues a row the dispatcher
1042-
* dead-letters. That is a real, separate defect — it is filed, ⛔ not
1043-
* widened into this ruling.
1040+
* 2. **An UNREGISTERED channel is not answered here.** It has no
1041+
* implementation to ask, so this consult cannot reach it and its absence
1042+
* is ⛔ NOT a suppression: `suppressed_channels` answers "why can this
1043+
* TENANT not send", and an unregistered channel is a composition fact.
1044+
* Both fan-out paths refuse it instead, reporting one failed
1045+
* {@link DeliveryOutcome} per `(recipient × channel)` and writing no
1046+
* delivery row at all — inline in {@link MessagingService.fanOut}, on the
1047+
* durable path in {@link MessagingService.enqueueDeliveries} (#18050).
10441048
* 3. **A throw is AVAILABLE.** Fail-open, matching the preference filter one
10451049
* step down: a broken probe must degrade into today's behaviour, never
10461050
* into a silent notification outage. Logged at `warn` — the degradation
@@ -1085,6 +1089,28 @@ export class MessagingService {
10851089
* dispatcher does the actual send + retry; here `ok` means "accepted for
10861090
* delivery" (enqueued), not yet delivered — progress is observable on the
10871091
* `sys_notification_delivery` row.
1092+
*
1093+
* ## An UNREGISTERED channel is refused here, not enqueued (#18050)
1094+
*
1095+
* A channel nobody registered has no transport to reach, so a row written
1096+
* for it is a row the dispatcher can only dead-letter on attempt ONE —
1097+
* `processRow` / `processDigestGroup` both ack `dead: true` the moment
1098+
* `getChannel()` answers nothing. Writing it costs an insert, a claim, an
1099+
* update and a retained terminal row per recipient, to record a fact known
1100+
* before the first write.
1101+
*
1102+
* The refusal is reported as the SAME failed {@link DeliveryOutcome} the
1103+
* inline path already produces for this case, so "nothing was sent and here
1104+
* is why" has one shape on both paths and the caller's `failed` count keeps
1105+
* its meaning.
1106+
*
1107+
* ⛔ NOT folded into `sys_notification.suppressed_channels`. That vocabulary
1108+
* answers "why can this TENANT not send on this channel" — a per-tenant
1109+
* configuration fact an operator filters and reports on. An unregistered
1110+
* channel is a COMPOSITION fact: identical for every tenant in the process,
1111+
* and fixed by mounting the channel, not by configuring the tenant.
1112+
* Recording it there would make a per-tenant report assert a
1113+
* deployment-wide misconfiguration.
10881114
*/
10891115
private async enqueueDeliveries(
10901116
outbox: INotificationOutbox,
@@ -1107,8 +1133,23 @@ export class MessagingService {
11071133
actorId: input.actorId,
11081134
};
11091135
const deliveries: DeliveryOutcome[] = [];
1136+
// [#18050] Counted, not logged in place: an emit to a 500-recipient
1137+
// audience would otherwise print 500 identical lines for one missing
1138+
// channel. Said ONCE per channel below, with the volume it refused —
1139+
// which is the number an operator needs to size the misconfiguration.
1140+
const refused = new Map<string, number>();
11101141
for (const { recipient, channels, notBefore, digest } of targets) {
11111142
for (const channel of channels) {
1143+
if (!this.channels.has(channel)) {
1144+
refused.set(channel, (refused.get(channel) ?? 0) + 1);
1145+
deliveries.push({
1146+
channel,
1147+
recipient,
1148+
ok: false,
1149+
error: `channel '${channel}' not registered`,
1150+
});
1151+
continue;
1152+
}
11121153
try {
11131154
const id = await outbox.enqueue({
11141155
notificationId,
@@ -1130,6 +1171,13 @@ export class MessagingService {
11301171
}
11311172
}
11321173
}
1174+
for (const [channel, count] of refused) {
1175+
this.ctx.logger.warn(
1176+
`[messaging] emit: channel '${channel}' is not registered; refused ${count} ` +
1177+
`delivery row(s) the dispatcher could only dead-letter. Register the channel ` +
1178+
`(or drop it from this notify's channel list) — nothing was sent on it.`,
1179+
);
1180+
}
11331181
return deliveries;
11341182
}
11351183

0 commit comments

Comments
 (0)