# Upgrade Guide — vNEXT

<!-- assembled-by: assemble-next-md -->
<!-- bump: patch -->

## What Changed

Makes `SessionManager.getTopicBinding()` collision-aware so a session shared by two same-named topics no longer drops one topic's messages. The tmux session name is derived by slugifying the topic NAME, so two Telegram topics whose names differ only by case ("Initiatives and maturation check-ins" #21487 vs "initiatives and maturation check-ins" #21624) collapse to one session name. `getTopicBinding` did a single-match reverse lookup over `topicToSession` and returned the FIRST topic — so the InputGuard bound the shared session to the older topic (21487) and blocked every message from the live topic (21624) as cross-topic. The session looked alive but was silently unresponsive (messages showed "Delivered" but never reached it). The fix: `getTopicBinding` now collects ALL topics mapping to the session and takes an optional `preferTopicId`; the `injectMessage` call site parses the message's own `[telegram:N]` tag and passes it, so a colliding session binds to the topic the message actually names. Single-topic sessions and the no-tag path are unchanged (fall back to first match). Migration-free — no session renaming, no registry rewrite.

## What to Tell Your User

If a session ever goes silent — messages show "Delivered" but it never replies, and restarting it doesn't help — one cause was two topics with near-identical names (e.g. differing only in capitalization) colliding onto the same underlying session, where a safety guard then blocked one of them. That's fixed: the agent now routes each message to the right topic by the tag the message carries, so the collision no longer eats messages.

## Summary of New Capabilities

| Capability | How to Use |
|-----------|-----------|
| Message routing survives same-name (case-variant) topic collisions | automatic — messages bind to the topic named by their own tag |

## Evidence

Reproduction (live, 2026-06-09): topic 21624 was unresponsive across multiple user messages and a respawn. Server logs showed `[InputGuard] BLOCKED cross-topic injection … session "echo-initiatives-and-maturation-check-ins" is bound to topic 21487` on every inbound 21624 message; the registry confirmed both 21487 ("Initiatives…") and 21624 ("initiatives…") mapped to that one session, and `getTopicBinding`'s reverse lookup returned the first (21487). Immediate recovery was an operator-authorized `/unlink` of the stale 21487; this PR is the durable fix.

After the fix: `tests/unit/topic-collision-binding.test.ts` (6 cases) pins both sides — a tagged message binds to the topic it names (21624 and 21487 each resolve correctly), the no-tag and unknown-tag paths fall back to first match, single-topic sessions are unchanged, and an unknown session returns null. tsc + lint clean; 63 existing SessionManager + InputGuard tests green.

## The problem

A conversation (Telegram topic 21624) stopped responding. Messages showed
"Delivered" but the agent never replied, and even restarting the session didn't
fix it. It looked dead, but it wasn't — its messages were being silently thrown
away before they ever reached it.

Here's the cause. Each running session lives in a "tmux session", and its name
is made by simplifying the topic's NAME into a slug. The trouble: there were two
Telegram topics with essentially the same name —

- #21487 "**I**nitiatives and maturation check-ins" (old, last used days ago)
- #21624 "**i**nitiatives and maturation check-ins" (your live one)

They differ only by one capital letter, so they slug to the **same** tmux name.
Two topics, one underlying session.

Now there's a safety guard whose whole job is to stop a message meant for topic A
from landing in topic B's session. When it asked "which topic does this session
belong to?", the lookup just returned the **first** one it found — the stale
21487. So when you messaged from 21624, the guard saw "this session belongs to
21487, but this message is tagged 21624 — mismatch!" and **dropped it**. Every
time. The session sat there, perfectly healthy, never hearing you.

## What already exists

- The reverse lookup `getTopicBinding(session)` that answers "which topic owns
  this session?"
- The InputGuard that compares a message's `[telegram:N]` tag to that answer and
  drops mismatches. Each part is correct on its own — the bug was only that the
  lookup couldn't handle two topics sharing one session.

## What's new

The lookup is now collision-aware. Instead of returning the first topic it finds,
it collects **all** topics that map to the session, and — because every message
carries its own `[telegram:N]` tag — it binds to the topic the message actually
names. So a message tagged 21624 now correctly resolves to 21624 and sails
through the guard, even though 21487 also shares that session.

## The safeguards in plain terms

- **No renaming, no migration.** This doesn't touch session names or rewrite any
  stored state — it just resolves the lookup more precisely at message time. So
  there's nothing to migrate and nothing to break for existing sessions.
- **Single-topic sessions are untouched.** When only one topic maps to a session
  (the normal case), behavior is identical to before.
- **Safe fallback.** If a message has no tag, or its tag names a topic that
  isn't on this session, it falls back to the old first-match behavior — never
  worse than before.
- **The guard still guards.** A genuinely cross-topic message (tag names a topic
  that does NOT share this session) is still blocked, exactly as intended.

## What you need to decide

Nothing — it ships as a normal patch with safe fallbacks. The earlier incident
was already recovered by clearing the stale topic by hand; this is the durable
fix so the collision can't silently eat messages again.
