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:
parent
38c09e5d73
commit
3f497e2b4f
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue