Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/relay-connector-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

Expand All @@ -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) | — |
Expand Down
2 changes: 1 addition & 1 deletion gateway/relay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 19 additions & 23 deletions gateway/relay/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ def __init__(
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] = {}
Expand Down Expand Up @@ -235,15 +235,15 @@ def _capture_scope(self, event) -> None:
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)
Expand All @@ -263,9 +263,9 @@ def _capture_scope(self, event) -> None:
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).
Expand All @@ -279,28 +279,24 @@ def _with_scope(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[
"""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
Expand Down Expand Up @@ -405,14 +401,14 @@ def _discord_interaction_to_event(self, forward):
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)
Expand Down
3 changes: 1 addition & 2 deletions gateway/relay/ws_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 29 additions & 29 deletions tests/gateway/relay/test_relay_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,75 +129,75 @@ async def send_outbound(self, action, *, platform=None):
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

src = SessionSource(
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

src = SessionSource(
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"]


Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/relay/test_relay_multiplatform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions tests/gateway/relay/test_relay_passthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@ async def fake_handle(event):
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"


Expand Down
12 changes: 6 additions & 6 deletions tests/gateway/relay/test_relay_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
Loading
Loading