From 3f497e2b4f92ef83f45a98c02f7cb47c12ee069e Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Sat, 1 Aug 2026 17:08:29 -0700 Subject: [PATCH] fix(gateway): relay thread-rename must carry the parent-channel discriminator (#76465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live staging (2026-08-01, on a fresh instance where title generation finally succeeded): the rename lane fired end to end, but the connector declined the op with "discord egress declined: target not routed to an onboarded tenant". The trace logs added earlier pinpointed it: discord auto-thread rename: thread=... lane=relay new_title='...' relay thread_rename declined ...: target not routed to an onboarded tenant discord auto-thread rename result: thread=... applied=False Root cause: the connector's routedEgressGuard resolves the owning tenant from the outbound metadata's scope_id (guild) or user_id (author). The adapter builds those via _with_scope(chat_id), reading per-chat caches keyed by the PARENT channel chat_id learned at inbound. The relay rename lane called rename_thread WITHOUT parent_chat_id, so chat_id defaulted to the THREAD id — a key the caches never held — and the op shipped with no discriminator. resolveTenant returned undefined and egress was declined before the op ever reached the (now-durable) no-clobber guard. This was the true terminal blocker: every earlier fix (send-result feedback, registration/poll ordering, connector-owned guard, durable Redis store) was correct but sat DOWNSTREAM of this egress-routing decline, so none of them could take effect. Fix: the relay lane passes parent_chat_id=source.chat_id (the relay source's chat_id IS the parent channel; the thread came from send-result feedback). _with_scope then resolves scope_id/user_id from the parent-channel caches and the connector routes the op to the tenant. Scoped to the relay lane only (use_connector_guard); the native lane renames via the direct Discord API and needs no discriminator. Tests: adapter-level — a rename passing parent_chat_id carries the cached scope_id, one keyed on the thread id alone does not (the regression shape); lane-level — the late-feedback test now asserts parent_chat_id flows through as the parent channel. Relay suite 150 passed; ruff + footguns clean. Connector-compatible with the deployed egress guard; no gateway-gateway change needed. --- gateway/run.py | 16 ++++++++ tests/gateway/relay/test_relay_threads.py | 46 +++++++++++++++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 9e6b2e745cb43..a00af86cf6b82 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18974,6 +18974,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew else getattr(source, "auto_thread_initial_name", None) ) thread_name = self._sanitize_discord_thread_title(title) + # Relay lane only: the connector's egress guard resolves the owning + # tenant from the outbound metadata's scope_id (guild) / user_id + # (author). Those discriminator caches are keyed by the PARENT channel + # chat_id (learned at inbound), NOT the thread id. rename_thread + # defaults chat_id to the thread id when no parent is given, so the + # scope/author lookup misses and the connector declines the op + # ("target not routed to an onboarded tenant" — the live failure on + # staging 2026-08-01). Pass the parent channel id (the relay source's + # chat_id IS the parent channel; the thread came from send-result + # feedback) so the discriminators resolve. Native lane needs nothing: + # its source IS the thread and it renames via the direct Discord API, + # not the relay egress guard. + parent_chat_id = ( + str(source.chat_id) if use_connector_guard and source.chat_id else None + ) logger.info( "discord auto-thread rename: thread=%s lane=%s new_title=%r", target_thread_id, @@ -18986,6 +19001,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew thread_name, prefer_connector_created=use_connector_guard, only_if_current_name=guard_name, + parent_chat_id=parent_chat_id, ) logger.info( "discord auto-thread rename result: thread=%s applied=%s", diff --git a/tests/gateway/relay/test_relay_threads.py b/tests/gateway/relay/test_relay_threads.py index c4aae3e495f9b..a0f5fe1522464 100644 --- a/tests/gateway/relay/test_relay_threads.py +++ b/tests/gateway/relay/test_relay_threads.py @@ -150,6 +150,40 @@ async def test_rename_thread_connector_guard_takes_precedence_over_string(): assert "only_if_current_name" not in action +@pytest.mark.asyncio +async def test_rename_thread_resolves_scope_from_parent_chat_not_thread(): + """The connector's egress guard resolves the owning tenant from the + outbound metadata's scope_id / user_id, and the adapter's discriminator + caches are keyed by the PARENT channel chat_id (learned at inbound), never + the thread id. A rename that passes parent_chat_id must carry that + discriminator; a rename keyed only on the thread id must not — reproducing + the live decline ("target not routed to an onboarded tenant") and its fix. + """ + adapter, stub = _adapter() + # Simulate the inbound-learned scope for the PARENT channel only. + adapter._scope_by_chat["chan-parent"] = "guild-123" + + # Fix: pass the parent chat id -> scope_id resolves. + await adapter.rename_thread( + "th-9", + "Real Title", + prefer_connector_created=True, + parent_chat_id="chan-parent", + ) + fixed = stub.sent[-1] + assert fixed["metadata"].get("scope_id") == "guild-123" + + # Regression shape: keyed on the thread id alone (no parent) -> no scope_id, + # which is exactly what made the connector decline the op. + await adapter.rename_thread( + "th-9", + "Real Title", + prefer_connector_created=True, + ) + unscoped = stub.sent[-1] + assert "scope_id" not in unscoped["metadata"] + + # ── the relay semantic-rename lane (marker parity) ─────────────────────── @@ -331,7 +365,7 @@ async def test_title_rename_polls_feedback_that_arrives_late(): prefer_connector_created=False, parent_chat_id=None, ): - renames.append((thread_id, name, prefer_connector_created)) + renames.append((thread_id, name, prefer_connector_created, parent_chat_id)) return True adapter.rename_thread = rename_thread # type: ignore[method-assign] @@ -348,8 +382,14 @@ async def test_title_rename_polls_feedback_that_arrives_late(): ) await task # Relay lane uses the connector-owned guard (prefer_connector_created=True), - # not the fragile cross-repo initial-name string. - assert renames == [("th-9", "Debugging the flux capacitor", True)] + # not the fragile cross-repo initial-name string. It MUST pass the PARENT + # channel chat_id so the connector's egress guard can resolve the tenant + # (the discriminator caches are keyed by the parent channel, not the thread; + # omitting it made the connector decline "target not routed to an onboarded + # tenant" — the live failure on staging 2026-08-01). + assert renames == [ + ("th-9", "Debugging the flux capacitor", True, "chan-parent") + ] @pytest.mark.asyncio