Skip to content

[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

Description

@os-project-manager

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 P0 fanOut ✅ present — messaging-service.ts:1212 this.channels.get(channelId), refusing at :1218 with error: `channel '${channelId}' not registered`
outbox P1 enqueueDeliveries (: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:

dispatcher.ts:283   await this.ackAttempt(row, { success: false, error: `channel '${channelName}' not registered`, dead: true });
dispatcher.ts:328   error: `channel '${row.channel}' not registered`, dead: true,

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:252send() 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:

  1. Does enqueueDeliveries refuse 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 records transport_not_configured? The second keeps one shape for "nothing was sent and here is why".
  2. 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.
  3. ⚠️ Anything here must not re-open 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 settled: the suppression key on sys_notification is written only when something was actually suppressed, so the common path's column set is unchanged. A fix that writes it unconditionally would re-break service-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

Grading

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)


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions