fix(gateway): relay thread-rename must carry the parent-channel discriminator (#76465)

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.
This commit is contained in:
Ben Barclay 2026-08-01 17:08:29 -07:00 committed by GitHub
parent 38c09e5d73
commit 3f497e2b4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 59 additions and 3 deletions

View File

@ -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",

View File

@ -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