Every Other Module Had the Guard

▶ Listen to this article

I had two agent sessions running in parallel — different chat tabs, different tasks, different contexts. Midway through a deploy, I noticed something wrong: console output from one session was appearing in the other tab. Tool call results bleeding across. Diff output from a file edit landing in the wrong conversation. Background job logs streaming to a tab that had nothing to do with the job.

The system had isolation. I’d built it. I’d tested it. And yet — there it was.

The architecture, briefly

The UA chat system streams events from server to browser over SSE. Different kinds of events are handled by different JavaScript modules: streaming text deltas, subagent status, task state changes, console output (tool results, diffs, subprocess logs). Each module registers handlers for its event types and updates the right parts of the UI.

When I added multi-tab support — multiple chat contexts running simultaneously in a single browser session — I needed each tab to only process events intended for it. The solution was straightforward: tag every event with a context_id, and have each module drop events that don’t match the active tab.

It worked. Most of the time.

The hunt

When I started tracing the console bleed, I pulled up the SSE handler modules and went through them one by one.

sse-deltas.js — handles streaming text. First thing in the handler:

if ((data.context_id || 'default') !== activeContextId) return;

sse-stream.js — handles stream state (start, stop, pause). Same guard, first line.

sse-handlers.js — routes task and subagent events. Guard present.

sse-subagents.js — background subagent status. Guard present.

sse-console.js — console output, tool results, diffs. No guard. None at all. Six event handlers, every single one writing directly to whatever streamingEl was in scope, no context check, no early return. Just: here is output, write it somewhere.

Every other module had the guard. This one didn’t.

Why this happens

It wasn’t an oversight in the usual sense — nobody forgot to think about isolation. The context_id filtering was a deliberate design choice, added at a specific point in the project’s history when multi-tab support was being built. The modules that existed at that moment got the guard. They were the ones in scope during that work.

sse-console.js was older. Or newer. The exact timing doesn’t matter much. What matters is that it wasn’t part of the same mental context when the isolation mechanism was designed and applied. The guard was added to “the SSE modules” in an informal sense — meaning the modules being actively worked on at the time, not every module in the system.

This is the natural shape of incremental development. You don’t build a system all at once. You add capabilities, fix bugs, refactor. Each session has a scope. Things outside that scope don’t get updated. Usually that’s fine. But when the thing you’re adding is a system-wide invariant — something that every code path needs to enforce — the incremental approach has a specific failure mode: the invariant ends up applied to most paths, but not all of them, and you don’t know which ones got missed.

The fix, and why it had to be two-sided

Fixing the client side was obvious: add the guard to each of the six console handlers. But that wasn’t quite enough.

The server side was also broken. Console events were being emitted without a context_id field — they had no tenant tag at all. If I only fixed the client side, the guard would check for context_id and find it missing, then fall through to the || 'default' fallback — meaning every console event would be treated as belonging to the default context. Any tab that happened to be the default context would still receive everything.

So the full fix was:

  1. Server-side: _emit_console() needed to inject context_id into every event it emitted, using the context of the originating session.
  2. Client-side: Each of the six console handlers needed the early-return guard.

Twelve lines across two files. Neither side alone was sufficient: without server-side tagging, the client guard has nothing to check. Without client-side filtering, the tags don’t do anything. Both were required. The wall needed to be built on both sides of the boundary.

The invariant you didn’t enforce everywhere

This failure mode isn’t specific to SSE event routing. It shows up anywhere you retrofit an isolation or security mechanism onto an existing system:

  • Auth middleware applied to every route you were thinking about, but not the one you added six months later during a different sprint
  • Rate limiting on all the API endpoints except the one you wired up quickly as a workaround
  • Multi-tenant database queries with row-level filters on every table except the one joined in for performance
  • Context isolation in an agent system, applied to every handler module that existed when isolation was designed

The mechanism is the same each time: you understand the concept correctly, you implement it in the places you’re thinking about, and somewhere — in a module written earlier, or added later, or touched by someone else — the invariant is missing.

The gap is usually invisible. Single-tenant systems work fine. Unit tests pass. You have to actually run two tenants concurrently and watch the data leak.

The audit you have to do explicitly

When you retrofit isolation, the instinct is to add the guard as you encounter each relevant code path. That’s usually how I work. It’s how the bug happened.

The more reliable approach: before you merge the change, write down every code path that touches tenant-specific state. Treat it like a checklist. Verify each one has the guard. Don’t rely on “I think I got them all” — that’s exactly the confidence level that produced the gap.

The wall I’d built was real. It covered almost everything. The gap was one module, twelve lines, and it only showed up when two things ran in parallel that weren’t supposed to see each other.

That’s always how it goes. The gap isn’t where you were thinking. It’s where you weren’t.