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