- 01.Bug one: a non-string field throws inside Claude's own handler
- 02.Bug two: the SDK stream that resolves into nothing
- 03.The apparatus that actually told the truth
- 04.The lesson
When 'Dispatched OK' Lies: Two Silent Drops in a Notification Bus
Inbound Slack reminders silently stopped surfacing in my Claude session. Every health check said 'fine' and the daemon logged 'dispatched OK' — but two distinct bugs were eating the events between send and render: a Zod throw on a non-string field, and an MCP stream that returns into the void.
My Claude session stopped receiving the notifications/claude/channel reminders that slack-bus pushes when someone reacts or replies in Slack. The daemon looked perfectly healthy: Socket Mode connected, uptime fine, and the log cheerfully printing notification → <session> dispatched OK on every event. Nothing surfaced in-session. This is the worst kind of failure — every surface I could check reported success, and the one thing I cared about, the event actually rendering, had no surface at all.
There were two separate bugs. Both produced the identical symptom: dispatched OK, never rendered. They lived at different layers, and I had to find them one at a time.
Bug one: a non-string field throws inside Claude's own handler
Claude Code validates the meta object on notifications/claude/channel as Record<string, string> using Zod, and it throws on any non-string value — inside its own notification handler. So the event is dropped before render while the sending daemon, oblivious, still logs the dispatch as successful.
The landmine was a type that was too loose. My meta was typed Record<string, unknown>. On the happy path every value was a string, so it worked for months. But user_name and channel_name come from lookup helpers that return undefined on a cache miss — and an undefined slipping into meta is exactly the value Zod rejects on the far side.
The fix is to stop trusting the happy path at the boundary:
function coerceMeta(meta: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(meta)) {
if (v == null) continue; // drop null/undefined entirely
out[k] = String(v); // everything else becomes a string
}
return out;
}Bug two: the SDK stream that resolves into nothing
The second bug is nastier because it lives in a dependency and fails by design. Inside @modelcontextprotocol/sdk, the streamable HTTP transport's send() does this:
const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId);
if (standaloneSse === undefined) { return; } // silent — no throwWhen a Claude client reloads or exits, it doesn't send an MCP DELETE, so the server's onsessionclosed never fires. The GET SSE stream is severed, but the session stays in the bus's table. Now .notification() resolves silently into the void, the .then() logs dispatched OK, and the event is simply gone — there's no event store to replay it from. A heartbeat ping wouldn't help either: pings hit the same silent return.
The operational consequence is the part worth tattooing on the wall: after a daemon restart or a client reload, test inbound from a fresh session. A session that was connected across the restart has a dead push stream and is a useless test rig. I learned this the expensive way — I used a scheduled wakeup to "wait" on a session whose stream was already dead, watched nothing arrive, and nearly concluded a completely unrelated flag was broken.
The apparatus that actually told the truth
The diagnosis hinged on realizing which signals were send-side only. dispatched OK proves the daemon called send(). It proves nothing about receipt. The only reliable confirmation is a full ground-truth loop:
- Post a message via the bus (which auto-subscribes the session to replies and reactions on it).
- Have a different identity react or reply — bot self-events are filtered out.
- The reminder should surface in one to three seconds. Healthy end-to-end is seconds, not minutes; silence past ten seconds means broken.
The deeper gap was that inbound had no log trail when an event matched zero subscriptions — the handler just returned. So you couldn't distinguish "never arrived" from "arrived, no matching sub" from "matched, but the stream is dead." I added a debug mode (off by default, because the underlying handler fires for every message in every channel and would flood the log) that drops a breadcrumb on every discard path. The key line is → 0 sessions (no matching sub): its presence proves Slack delivered the event, which isolates the failure to a missing subscription versus a dead socket. With the flag off there's no behavior change.
The lesson
A success log written by the sender is not evidence of receipt — it's evidence the sender finished its part. Both of these bugs hid in exactly that gap: one component logged "done" for an operation that another component, or the transport itself, silently abandoned. The fix that generalizes isn't either patch. It's that every drop should leave a breadcrumb. When an event can vanish, the absence of a log line has to mean something — otherwise "everything looks fine" is the most expensive sentence in the system.