You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[finding] The outbox fan-out writes a delivery row for a channel that is NOT registered — #17732's reported symptom, which ruling A does not reach #18050
Split out of #17732's delivery (PR #18041). Its dev found that the card's own premise is half wrong, and the wrong half is the symptom the card opens with. Verified in-seat on the delivery head 684df79a9, ⛔ not taken from the report.
The premise, and where it fails
#17732 states that fan-out "only checks that the channel is REGISTERED". Measured:
path
registration check
inline P0fanOut
✅ present — messaging-service.ts:1212this.channels.get(channelId), refusing at :1218 with error: `channel '${channelId}' not registered`
outbox P1enqueueDeliveries (:1089)
⛔ none — its loops (for … of targets / for (const channel of channels)) contain no channels.get, no channels.has, no refusal
⇒ on the outbox path a channel named in channels but never registered gets one sys_notification_delivery row per recipient, and NotificationDispatcher dead-letters every one of them on attempt one:
That is exactly the symptom #17732 opens with — a row written that dies on its first attempt.
Why ruling A does not reach it, and why that is not a gap in the ruling
Ruling 5644350987 gives MessagingChannel an optional isAvailable(). That member is a property of a channel implementation — and an unregistered channel has no implementation to ask. So the ruled mechanism is structurally unable to cover this case; ⛔ it was not an oversight in the ruling and ⛔ not a shortfall in #18041, which correctly left it on its existing path and pinned the boundary in channel-availability.test.ts so it cannot be closed by accident.
⚠️ It is filed rather than left as a note because it is the parent card's reported symptom, still live after that card closes. A defect whose successor is "whoever next touches fan-out" is a defect that gets lost.
The second half — registration is a ONE-SHOT VERDICT, and the code says otherwise
messaging-service-plugin.ts:261-268:
// The dispatcher looks channels up dynamically, so registering after it is fine.ctx.hook('kernel:ready',async()=>{if(getEmail()){service.registerChannel(createEmailChannel({ … }));}});
⭐ The comment's own reasoning is contradicted by the guard beneath it: "registering after it is fine" is true of the dispatcher, but the if (getEmail()) runs once, at kernel:ready, and nothing revisits it. An email service that registers later never gets its channel — and then every notify(channels:['email']) takes the unregistered path above.
That is the three-part shape AGENTS.md's "Startup registry reads" section names: a read of a still-filling registry, a terminal conclusion drawn from absence, and that conclusion recorded as a non-registration nothing revisits. The SMS channel beneath it carries the same pattern.
⇒ the two halves meet in one composition, which is why they are one card.
Adjacent, recorded here rather than filed separately
email-channel.ts:252 — send() returns { ok: true } when the capability is absent ("capability not installed — no-op, like inbox w/o data"), so a delivery row records success with nothing sent. ⚠️ After #18041 this is no longer reachable through fan-out (the new isAvailable at :238 answers transport_not_configured first), but it stays reachable by a direct send() from the dispatcher. ⛔ Not this card's fix; noted because the suppression path now hides it from the route that used to exercise it.
What a fix has to decide
⛔ Not prescribed here — these are the forks, so whoever takes it does not rediscover them:
Should registration stop being a one-shot verdict — i.e. resolve the channel at dispatch time rather than at kernel:ready? The comment already claims the dispatcher supports this.
domain:services (landing site packages/services/service-messaging), priority:p3 inherited from the parent. ⚠️ Triage may re-rank: unlike #17732's workless-but-attemptable rows, these rows are guaranteed dead on attempt one, which is a worse shape in the same hot table.
Refs
#17732 (parent) · PR #18041 (the delivery that found it) · 5644350987 (ruling A) · 5650914775 (the cross-domain routing)
Split out of #17732's delivery (PR #18041). Its dev found that the card's own premise is half wrong, and the wrong half is the symptom the card opens with. Verified in-seat on the delivery head
684df79a9, ⛔ not taken from the report.The premise, and where it fails
#17732 states that fan-out "only checks that the channel is REGISTERED". Measured:
fanOutmessaging-service.ts:1212this.channels.get(channelId), refusing at:1218witherror: `channel '${channelId}' not registered`enqueueDeliveries(:1089)for … of targets/for (const channel of channels)) contain nochannels.get, nochannels.has, no refusal⇒ on the outbox path a channel named in
channelsbut never registered gets onesys_notification_deliveryrow per recipient, andNotificationDispatcherdead-letters every one of them on attempt one:That is exactly the symptom #17732 opens with — a row written that dies on its first attempt.
Why ruling A does not reach it, and why that is not a gap in the ruling
Ruling
5644350987givesMessagingChannelan optionalisAvailable(). That member is a property of a channel implementation — and an unregistered channel has no implementation to ask. So the ruled mechanism is structurally unable to cover this case; ⛔ it was not an oversight in the ruling and ⛔ not a shortfall in #18041, which correctly left it on its existing path and pinned the boundary inchannel-availability.test.tsso it cannot be closed by accident.The second half — registration is a ONE-SHOT VERDICT, and the code says otherwise
messaging-service-plugin.ts:261-268:⭐ The comment's own reasoning is contradicted by the guard beneath it: "registering after it is fine" is true of the dispatcher, but the
if (getEmail())runs once, atkernel:ready, and nothing revisits it. An email service that registers later never gets its channel — and then everynotify(channels:['email'])takes the unregistered path above.That is the three-part shape AGENTS.md's "Startup registry reads" section names: a read of a still-filling registry, a terminal conclusion drawn from absence, and that conclusion recorded as a non-registration nothing revisits. The SMS channel beneath it carries the same pattern.
⇒ the two halves meet in one composition, which is why they are one card.
Adjacent, recorded here rather than filed separately
email-channel.ts:252—send()returns{ ok: true }when the capability is absent ("capability not installed — no-op, like inbox w/o data"), so a delivery row records success with nothing sent.isAvailableat:238answerstransport_not_configuredfirst), but it stays reachable by a directsend()from the dispatcher. ⛔ Not this card's fix; noted because the suppression path now hides it from the route that used to exercise it.What a fix has to decide
⛔ Not prescribed here — these are the forks, so whoever takes it does not rediscover them:
enqueueDeliveriesrefuse an unregistered channel (no row at all), or record it as suppressed the way feat(service-messaging,platform-objects): fan-out consults per-tenant channel availability and writes no delivery row for a channel with no transport (#17732) #18041 recordstransport_not_configured? The second keeps one shape for "nothing was sent and here is why".kernel:ready? The comment already claims the dispatcher supports this.sys_notificationis written only when something was actually suppressed, so the common path's column set is unchanged. A fix that writes it unconditionally would re-breakservice-automation's zero-delivery harness — which is exactly what feat(service-messaging,platform-objects): fan-out consults per-tenant channel availability and writes no delivery row for a channel with no transport (#17732) #18041's first push did, and it was fixed in the producer rather than by retuning that test.Fences
packages/specis not this lane's — the reason vocabulary is inlined on the object (sys-notification.object.ts:96declaresseverity: Field.select([...])in place, the precedent feat(service-messaging,platform-objects): fan-out consults per-tenant channel availability and writes no delivery row for a channel with no transport (#17732) #18041 followed). If a spec edit looks necessary, STOP and route it.content/docs/releases/**untouched.packages/platform-objects/**isdomain:engine. feat(service-messaging,platform-objects): fan-out consults per-tenant channel availability and writes no delivery row for a channel with no transport (#17732) #18041 reached it through triage's cross-domain exception path (5650914775) with a declared file surface; ⛔ do not assume that permission carries over — if this fix needs that package, it needs its own routing.Grading
domain:services(landing sitepackages/services/service-messaging),priority:p3inherited from the parent.Refs
#17732 (parent) · PR #18041 (the delivery that found it) ·
5644350987(ruling A) ·5650914775(the cross-domain routing)Generated by Claude Code