refactor(relay): purge platform-specific scope terminology from the relay adapter (D-Q2.5c) (#56016)

The gateway HALF of the D-Q2.5c cleanup (connector half: gateway-gateway #92).
Scope is STRICTLY the relay adapter (gateway/relay/) — session.py and every
native platform adapter are untouched (SessionSource.guild_id remains for their
use; it is NOT relay-only).

Within gateway/relay/, drop the D-Q2.5 wire dual-write/dual-read alias AND
genericize all platform-specific (Discord "guild") scope terminology:
- ws_transport._event_from_wire: read scope_id only (drop the ?? guild_id fallback).
- adapter._with_scope: emit scope_id only on outbound metadata (drop the
  guild_id dual-write); genericize the "GUILD reply" docstring to "SCOPED reply".
- adapter._capture_scope: read source.scope_id only; rename the local `guild`
  var to `scope`; genericize the docstring + the _scope_by_chat/_dm_user_by_chat
  field comments ("guild_id (Discord)" -> "scope_id (server/workspace scope)").
- __init__.relay_route_keys docstring: "guild_ids" -> "scope_ids".
- The ONE real Discord `guild_id` kept: the raw inbound interaction payload
  field (payload.get("guild_id")), which is Discord's own wire field, mapped
  straight into the generic scope_id slot — unchanged.

Contract doc (docs/relay-connector-contract.md): reframe the `guild_id` row as
a legacy alias the connector no longer reads (session.py's agent-wide to_dict()
still emits it for non-relay persistence, so it stays documented + wire-present
but ignored) — accurate, and keeps the to_dict()-vs-doc conformance test green.

Tests (relay only): migrate the wire-key writes + assertions guild_id -> scope_id
across test_relay_adapter / _ws_transport / _passthrough / _roundtrip /
_roundtrip_telegram / _multiplatform; keep raw Discord `type:2` interaction
payloads' guild_id (real Discord field) and the conformance test's guild_id
parametrize (validates the kept legacy field stays wire-reachable).

Gate: 156 relay tests pass, ruff clean. Cross-repo E2E — all 14 drivers pass
BOTH ways: connector#92 (scope_id-only) x agent-main (still dual-reads) AND
connector#92 x this worktree (scope_id-only). Deploy-order-safe either way.
This commit is contained in:
Ben Barclay 2026-07-01 12:30:59 +10:00 committed by GitHub
parent a653bb0cbe
commit 729bbb7a30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 70 additions and 75 deletions

View file

@ -157,7 +157,7 @@ present (may be `null`); the rest are included only when set.
| `user_id_alt` | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
| `chat_id_alt` | string | no | Alternate chat id (e.g. Signal group internal id). |
| `scope_id` | string | no | Platform-neutral **scope** discriminator: Discord guild / Slack workspace / Matrix server. **REQUIRED for Discord/Slack scope isolation.** Session-key discriminator. (Canonical name as of the D-Q2.5 wire migration.) |
| `guild_id` | string | no | **Deprecated alias for `scope_id`** — still emitted and read during the cross-repo dual-read/dual-write overlap; readers resolve `scope_id ?? guild_id`. Dropped once both repos deploy on `scope_id`. |
| `guild_id` | string | no | **Legacy alias, no longer read by the connector.** As of D-Q2.5c the connector reads and writes only `scope_id`; the gateway's agent-wide `SessionSource.to_dict()` still emits `guild_id` (mirrored to `scope_id`) for non-relay session persistence, so it may still appear on the wire but the connector ignores it. Do not depend on it. |
| `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. |
| `message_id` | string | no | Id of the triggering message (for pin/reply/react). |
@ -168,7 +168,7 @@ present (may be `null`); the rest are included only when set.
### SessionSource discriminators per platform
| Platform | chat_id | chat_type | user_id | thread_id | guild_id |
| Platform | chat_id | chat_type | user_id | thread_id | scope_id |
| --- | --- | --- | --- | --- | --- |
| **Discord** | channel id | `dm`/`group`/`thread` | author id | thread channel id (threads) | **guild id** (REQUIRED for server isolation) |
| **Telegram** | chat id | `dm`/`group`/`forum` | from id | forum topic id (forums) | — |

View file

@ -172,7 +172,7 @@ def relay_endpoint() -> Optional[str]:
def relay_route_keys() -> list[str]:
"""Discriminators (guild_ids / chat_ids / paths) this gateway's tenant owns.
"""Discriminators (scope_ids / chat_ids / paths) this gateway's tenant owns.
Gateway-provided config, paired with ``relay_endpoint()``: the connector
writes one route row per (routeKey -> tenant, endpoint), so route keys only

View file

@ -59,15 +59,15 @@ class RelayAdapter(BasePlatformAdapter):
self._transport = transport
# Capability surface read by stream_consumer (getattr(..., 4096)).
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
# chat_id -> guild_id (Discord) / workspace scope, learned from inbound
# chat_id -> scope_id (server/workspace scope), learned from inbound
# events. The connector's egress guard resolves the owning tenant from
# the OUTBOUND action's metadata.guild_id; the gateway's generic delivery
# the OUTBOUND action's metadata.scope_id; the gateway's generic delivery
# path (run.py _thread_metadata_for_source) only carries thread_id, so we
# re-attach the scope here from what we saw inbound. Keyed by chat_id
# (channel) since that's what send() receives. See routedEgressGuard.ts.
self._scope_by_chat: Dict[str, str] = {}
# chat_id -> author user_id for DM channels (no guild_id). A DM reply has
# no guild discriminator, so the connector resolves its tenant from the
# chat_id -> author user_id for DM chats (no scope). A DM reply has
# no scope discriminator, so the connector resolves its tenant from the
# recipient's author binding; we re-attach this user_id as
# metadata.user_id on the outbound action so it can. See _capture_scope.
self._dm_user_by_chat: Dict[str, str] = {}
@ -235,15 +235,15 @@ class RelayAdapter(BasePlatformAdapter):
tenant resolution. Never raises scope tracking must not break inbound.
Two cases, matching the connector's two tenant-resolution paths:
- GUILD message: remember chat_id -> guild_id. The connector resolves
the tenant from metadata.guild_id (routing table).
- DM (no guild_id): remember chat_id -> the authentic author user_id.
A DM carries no guild discriminator, so the connector instead resolves
- SCOPED message: remember chat_id -> scope_id. The connector resolves
the tenant from metadata.scope_id (routing table).
- DM (no scope): remember chat_id -> the authentic author user_id.
A DM carries no scope discriminator, so the connector instead resolves
the tenant from the recipient's author binding (resolveByUser); it
needs the user_id on the OUTBOUND action to do that. Without this, a
DM reply has no resolvable discriminator and the connector's egress
guard declines it as "target not routed to an onboarded tenant".
See gateway-gateway routedEgressGuard.ts / discordTenantOf.
See gateway-gateway routedEgressGuard.ts / the tenant resolvers.
"""
try:
src = getattr(event, "source", None)
@ -263,9 +263,9 @@ class RelayAdapter(BasePlatformAdapter):
platform_value = getattr(platform, "value", platform)
if platform_value and platform_value != "relay":
self._platform_by_chat[str(chat)] = str(platform_value)
guild = getattr(src, "scope_id", None) or getattr(src, "guild_id", None)
if guild:
self._scope_by_chat[str(chat)] = str(guild)
scope = getattr(src, "scope_id", None)
if scope:
self._scope_by_chat[str(chat)] = str(scope)
return
# DM: no scope. Remember the authentic author id for outbound
# author-binding resolution (the user we're replying to in this DM).
@ -279,28 +279,24 @@ class RelayAdapter(BasePlatformAdapter):
"""Ensure the outbound metadata carries the discriminator the connector's
egress guard needs to resolve the owning tenant. Two cases:
- GUILD reply: re-attach metadata.scope_id (routing-table resolution;
also mirrored to the deprecated metadata.guild_id during the D-Q2.5
wire migration so a connector on either side resolves the tenant).
- SCOPED reply: re-attach metadata.scope_id (routing-table resolution).
- DM reply: there is no scope, so re-attach metadata.user_id the
authentic author id we saw inbound which the connector resolves to
the tenant via the recipient's author binding (resolveByUser). Without
one of these, egress is declined as 'target not routed to an onboarded
tenant'. See gateway-gateway routedEgressGuard.ts / discordTenantOf.
tenant'. See gateway-gateway routedEgressGuard.ts / the tenant resolvers.
No-op when the relevant value is already present or unknown for this chat.
"""
meta: Dict[str, Any] = dict(metadata or {})
if not meta.get("scope_id") and not meta.get("guild_id"):
if not meta.get("scope_id"):
scope = self._scope_by_chat.get(str(chat_id))
if scope:
# D-Q2.5 dual-write: canonical scope_id + deprecated guild_id alias.
meta["scope_id"] = scope
meta["guild_id"] = scope
# DM author-binding discriminator. Only meaningful when there's no scope
# (a guild reply resolves by scope_id); harmless to carry otherwise, but
# (a scoped reply resolves by scope_id); harmless to carry otherwise, but
# we only set it when this chat is a known DM and the field is absent.
if not meta.get("scope_id") and not meta.get("guild_id") and not meta.get("user_id"):
if not meta.get("scope_id") and not meta.get("user_id"):
dm_user = self._dm_user_by_chat.get(str(chat_id))
if dm_user:
meta["user_id"] = dm_user
@ -405,14 +401,14 @@ class RelayAdapter(BasePlatformAdapter):
member = payload.get("member") or {}
user = (member.get("user") if isinstance(member, dict) else None) or payload.get("user") or {}
channel_id = str(payload.get("channel_id") or "")
guild_id = payload.get("guild_id") # real Discord interaction field
guild_id = payload.get("guild_id") # real Discord interaction wire field
source = SessionSource(
platform=Platform.RELAY,
chat_id=channel_id,
chat_type="channel" if guild_id else "dm",
user_id=str(user.get("id")) if isinstance(user, dict) and user.get("id") else None,
user_name=str(user.get("username")) if isinstance(user, dict) and user.get("username") else None,
scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot (D-Q2.5)
scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot
message_id=str(payload.get("id")) if payload.get("id") else None,
)
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)

View file

@ -117,8 +117,7 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
chat_topic=src.get("chat_topic"),
user_id_alt=src.get("user_id_alt"),
chat_id_alt=src.get("chat_id_alt"),
# D-Q2.5 dual-read: prefer canonical scope_id, fall back to legacy guild_id.
scope_id=src.get("scope_id", src.get("guild_id")),
scope_id=src.get("scope_id"),
parent_chat_id=src.get("parent_chat_id"),
message_id=src.get("message_id"),
# Authentic upstream-trust signal: this event arrived over the

View file

@ -129,7 +129,7 @@ class _CaptureTransport:
return {"success": True, "message_id": "m1"}
def _make_event(chat_id="chan-1", guild_id="guild-9"):
def _make_event(chat_id="chan-1", scope_id="scope-9"):
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
@ -137,13 +137,13 @@ def _make_event(chat_id="chan-1", guild_id="guild-9"):
platform=Platform.RELAY,
chat_id=chat_id,
chat_type="channel",
guild_id=guild_id,
scope_id=scope_id,
)
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
def _make_dm_event(chat_id="dm-1", user_id="user-42"):
"""An inbound DM: no guild_id, carries the authentic author user_id."""
"""An inbound DM: no scope_id, carries the authentic author user_id."""
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
@ -151,53 +151,53 @@ def _make_dm_event(chat_id="dm-1", user_id="user-42"):
platform=Platform.RELAY,
chat_id=chat_id,
chat_type="dm",
guild_id=None,
scope_id=None,
user_id=user_id,
)
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
@pytest.mark.asyncio
async def test_send_reattaches_guild_id_from_inbound_scope():
async def test_send_reattaches_scope_id_from_inbound_scope():
"""The connector's egress guard resolves the owning tenant from
metadata.guild_id; the gateway's generic delivery path drops it, so the
relay adapter must re-attach the guild scope learned from the inbound event.
Regression for live 'discord egress declined: target not routed to an
metadata.scope_id; the gateway's generic delivery path drops it, so the
relay adapter must re-attach the scope learned from the inbound event.
Regression for live 'egress declined: target not routed to an
onboarded tenant'."""
t = _CaptureTransport()
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
# Simulate the connector delivering an inbound message in guild-9 / chan-1,
# Simulate the connector delivering an inbound message in scope-9 / chan-1,
# but don't run the full handle_message pipeline — just the scope capture.
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))
await a.send("chan-1", "the reply")
assert t.sent["metadata"].get("guild_id") == "guild-9"
assert t.sent["metadata"].get("scope_id") == "scope-9"
@pytest.mark.asyncio
async def test_send_without_known_scope_omits_guild_id():
"""A chat we never saw inbound (e.g. a DM) gets no guild_id — no-op, never
async def test_send_without_known_scope_omits_scope_id():
"""A chat we never saw inbound (e.g. a DM) gets no scope_id — no-op, never
invents a scope."""
t = _CaptureTransport()
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
await a.send("unknown-chat", "hi")
assert "guild_id" not in t.sent["metadata"]
assert "scope_id" not in t.sent["metadata"]
@pytest.mark.asyncio
async def test_send_preserves_explicit_guild_id():
"""An explicitly-provided metadata.guild_id is never overwritten."""
async def test_send_preserves_explicit_scope_id():
"""An explicitly-provided metadata.scope_id is never overwritten."""
t = _CaptureTransport()
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
await a.send("chan-1", "hi", metadata={"guild_id": "explicit-1"})
assert t.sent["metadata"]["guild_id"] == "explicit-1"
a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))
await a.send("chan-1", "hi", metadata={"scope_id": "explicit-1"})
assert t.sent["metadata"]["scope_id"] == "explicit-1"
@pytest.mark.asyncio
async def test_send_reattaches_dm_user_id_from_inbound_scope():
"""A DM reply has no guild_id, so the connector resolves the tenant from the
"""A DM reply has no scope_id, so the connector resolves the tenant from the
recipient's author binding — it needs metadata.user_id. The adapter must
re-attach the authentic author id learned from the inbound DM. Regression for
live 'discord egress declined: target not routed to an onboarded tenant' on
@ -209,8 +209,8 @@ async def test_send_reattaches_dm_user_id_from_inbound_scope():
await a.send("dm-1", "the reply")
assert t.sent["metadata"].get("user_id") == "user-42"
# A DM carries no guild_id — only the author discriminator.
assert "guild_id" not in t.sent["metadata"]
# A DM carries no scope_id — only the author discriminator.
assert "scope_id" not in t.sent["metadata"]
@pytest.mark.asyncio
@ -220,7 +220,7 @@ async def test_send_dm_does_not_invent_user_id_for_unknown_chat():
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
await a.send("unknown-dm", "hi")
assert "user_id" not in t.sent["metadata"]
assert "guild_id" not in t.sent["metadata"]
assert "scope_id" not in t.sent["metadata"]
@pytest.mark.asyncio
@ -234,15 +234,15 @@ async def test_send_preserves_explicit_user_id():
@pytest.mark.asyncio
async def test_guild_reply_does_not_carry_user_id():
"""A guild reply resolves by guild_id and must NOT carry a DM user_id even if
the same chat_id was somehow seen guild capture wins and user_id stays out
(guild_id is the discriminator; user_id is the DM-only fallback)."""
async def test_scoped_reply_does_not_carry_user_id():
"""A scoped reply resolves by scope_id and must NOT carry a DM user_id even if
the same chat_id was somehow seen scope capture wins and user_id stays out
(scope_id is the discriminator; user_id is the DM-only fallback)."""
t = _CaptureTransport()
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))
await a.send("chan-1", "hi")
assert t.sent["metadata"].get("guild_id") == "guild-9"
assert t.sent["metadata"].get("scope_id") == "scope-9"
assert "user_id" not in t.sent["metadata"]

View file

@ -194,7 +194,7 @@ async def test_adapter_stamps_per_frame_platform_from_inbound(monkeypatch):
MessageEvent(
text="yo",
message_type=MessageType.TEXT,
source=SessionSource(platform=Platform.DISCORD, chat_id="dc-1", chat_type="channel", guild_id="g-1"),
source=SessionSource(platform=Platform.DISCORD, chat_id="dc-1", chat_type="channel", scope_id="g-1"),
)
)
await adapter.send("dc-1", "a discord reply")

View file

@ -120,10 +120,10 @@ async def test_discord_interaction_routes_through_handle_message(adapter, monkey
ev = seen[0]
assert ev.text == "summarize"
assert ev.source.chat_id == "chan-9"
assert ev.source.guild_id == "guild-7"
assert ev.source.scope_id == "guild-7"
assert ev.source.user_id == "user-3"
assert ev.source.chat_type == "channel"
# Scope captured so the agent's reply re-asserts guild_id for egress.
# Scope captured so the agent's reply re-asserts scope_id for egress.
assert adapter._scope_by_chat.get("chan-9") == "guild-7"

View file

@ -3,7 +3,7 @@
Proves the gateway side of the relay works with no real connector:
- connect() registers the inbound handler,
- a connector-delivered MessageEvent reaches the adapter's message path,
- SessionSource discriminators (guild_id) drive build_session_key isolation,
- SessionSource discriminators (scope_id) drive build_session_key isolation,
- an outbound send round-trips through the transport.
These target the transport contract + session-key derivation (Task 1.2's gate),
@ -40,14 +40,14 @@ def _discord_descriptor() -> CapabilityDescriptor:
)
def _discord_event(guild_id: str, channel_id: str, user_id: str, text: str) -> MessageEvent:
def _discord_event(scope_id: str, channel_id: str, user_id: str, text: str) -> MessageEvent:
"""Synthetic inbound the connector would build from a discord.js message."""
source = SessionSource(
platform=Platform.DISCORD,
chat_id=channel_id,
chat_type="group",
user_id=user_id,
guild_id=guild_id,
scope_id=scope_id,
)
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
@ -79,18 +79,18 @@ async def test_inbound_event_reaches_adapter(wired, monkeypatch):
await stub.push_inbound(ev)
assert len(captured) == 1
assert captured[0].text == "hello"
assert captured[0].source.guild_id == "guildA"
assert captured[0].source.scope_id == "guildA"
@pytest.mark.asyncio
async def test_two_guilds_isolate_into_distinct_session_keys(wired):
async def test_two_scopes_isolate_into_distinct_session_keys(wired):
adapter, _ = wired
ev_a = _discord_event("guildA", "chan1", "userX", "hi from A")
ev_b = _discord_event("guildB", "chan2", "userX", "hi from B")
key_a = build_session_key(ev_a.source)
key_b = build_session_key(ev_b.source)
assert key_a != key_b
# Same guild + channel + user collapses to one session.
# Same scope + channel + user collapses to one session.
ev_a2 = _discord_event("guildA", "chan1", "userX", "again")
assert build_session_key(ev_a2.source) == key_a

View file

@ -6,9 +6,9 @@ descriptors to round-trip and their inbound ``MessageEvent``s to drive
``build_session_key()`` correctly.
Telegram's discriminator profile differs from Discord's, which is the point:
- No ``guild_id``; isolation between chats comes from ``chat_id`` alone.
- No ``scope_id``; isolation between chats comes from ``chat_id`` alone.
- Forum topics live inside ONE ``chat_id`` and isolate by ``thread_id`` (the
Telegram analog of Discord's per-guild isolation).
Telegram analog of Discord's per-scope isolation).
- Forum/thread sessions are shared across participants by default
(``thread_sessions_per_user=False``) user_id is NOT appended in a thread.
- ``len_unit="utf16"`` (Telegram counts UTF-16 code units) and
@ -51,7 +51,7 @@ def _tg_group_event(chat_id: str, user_id: str, text: str, thread_id: str | None
"""Synthetic inbound the connector would build from a Telegram update.
A plain group message has no thread_id; a forum-topic message carries the
topic id as thread_id (no guild_id Telegram has no guild concept).
topic id as thread_id (no scope_id Telegram has no scope concept).
"""
source = SessionSource(
platform=Platform.TELEGRAM,
@ -105,13 +105,13 @@ async def test_inbound_telegram_event_reaches_adapter(wired, monkeypatch):
assert len(captured) == 1
assert captured[0].text == "hello"
assert captured[0].source.platform == Platform.TELEGRAM
assert captured[0].source.guild_id is None # Telegram has no guild
assert captured[0].source.scope_id is None # Telegram has no scope
@pytest.mark.asyncio
async def test_two_telegram_chats_isolate_by_chat_id(wired):
"""No guild_id on Telegram — two distinct chats must still isolate, keyed
on chat_id alone (the Discord-guild role is played by chat_id here)."""
"""No scope_id on Telegram — two distinct chats must still isolate, keyed
on chat_id alone (the Discord-scope role is played by chat_id here)."""
ev_a = _tg_group_event("chat-A", "userX", "hi A")
ev_b = _tg_group_event("chat-B", "userX", "hi B")
key_a = build_session_key(ev_a.source)
@ -125,7 +125,7 @@ async def test_two_telegram_chats_isolate_by_chat_id(wired):
@pytest.mark.asyncio
async def test_forum_topics_isolate_by_thread_id_within_one_chat(wired):
"""Telegram forum topics share a single chat_id and isolate by thread_id —
the Telegram analog of Discord per-guild isolation. Two topics in the same
the Telegram analog of Discord per-scope isolation. Two topics in the same
forum must NOT collide, and (threads shared across participants by default)
a second user in the same topic shares the session."""
topic1 = _tg_group_event("forum-1", "userX", "in topic 1", thread_id="t-1")

View file

@ -117,7 +117,7 @@ async def test_inbound_frame_reaches_handler(server):
"event": {
"text": "hello from connector",
"message_type": "text",
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "guild_id": "guildA"},
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "scope_id": "guildA"},
},
"bufferId": "buf-1",
}
@ -132,7 +132,7 @@ async def test_inbound_frame_reaches_handler(server):
await asyncio.sleep(0.05)
assert len(received) == 1
assert received[0].text == "hello from connector"
assert received[0].source.guild_id == "guildA"
assert received[0].source.scope_id == "guildA"
finally:
await t.disconnect()