From 3a55f66602898b53524a232e3dbadea3ed9b0cf8 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 30 Jun 2026 11:16:53 +1000 Subject: [PATCH] =?UTF-8?q?refactor(relay):=20adopt=20scope=5Fid=20wire=20?= =?UTF-8?q?key=20(guild=5Fid=20=E2=86=92=20scope=5Fid=20dual-read/write)?= =?UTF-8?q?=20(#55289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway half of relay-platform-parity Phase 2.5 (D-Q2.5). The relay wire's platform-neutral scope discriminator is renamed guild_id → scope_id; this is the hermes-agent side of the cross-repo wire-compatible migration. - SessionSource: scope_id is canonical; guild_id kept as @deprecated alias. __post_init__ mirrors the two so all existing SessionSource(guild_id=...) constructors across native adapters keep working unchanged. to_dict dual-WRITES scope_id+guild_id; from_dict dual-READS scope_id ?? guild_id. - relay/adapter.py: capture + outbound metadata dual-read/write scope_id. - relay/ws_transport.py: _frame_to_event dual-reads scope_id ?? guild_id. - docs/relay-connector-contract.md: document scope_id (canonical) + guild_id (deprecated alias) in the §3 SessionSource field table (conformance test). 250 relay+session+contract tests green. Solo lane (relay). --- docs/relay-connector-contract.md | 3 ++- gateway/relay/adapter.py | 24 +++++++++++++---------- gateway/relay/ws_transport.py | 3 ++- gateway/session.py | 33 ++++++++++++++++++++++++++++---- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 68dcd1924c1..8c45c401d47 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -156,7 +156,8 @@ present (may be `null`); the rest are included only when set. | `chat_topic` | string\|null | yes | Channel topic/description (Discord, Slack). | | `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). | -| `guild_id` | string | no | Discord guild / Slack workspace / Matrix server scope. **REQUIRED for Discord server isolation.** Session-key discriminator. | +| `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`. | | `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). | diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index b7cceb85469..3dc81d9ec34 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -263,11 +263,11 @@ 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, "guild_id", None) + guild = getattr(src, "scope_id", None) or getattr(src, "guild_id", None) if guild: self._scope_by_chat[str(chat)] = str(guild) return - # DM: no guild_id. Remember the authentic author id for outbound + # DM: no scope. Remember the authentic author id for outbound # author-binding resolution (the user we're replying to in this DM). user_id = getattr(src, "user_id", None) if user_id: @@ -279,8 +279,10 @@ 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.guild_id (routing-table resolution). - - DM reply: there is no guild_id, so re-attach metadata.user_id — the + - 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). + - 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 @@ -289,14 +291,16 @@ class RelayAdapter(BasePlatformAdapter): 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("guild_id"): + if not meta.get("scope_id") and not meta.get("guild_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 guild - # (a guild reply resolves by guild_id); harmless to carry otherwise, but + # DM author-binding discriminator. Only meaningful when there's no scope + # (a guild 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("guild_id") and not meta.get("user_id"): + if not meta.get("scope_id") and not meta.get("guild_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 @@ -401,14 +405,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") + guild_id = payload.get("guild_id") # real Discord interaction 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, - guild_id=str(guild_id) if guild_id else None, + scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot (D-Q2.5) message_id=str(payload.get("id")) if payload.get("id") else None, ) return MessageEvent(text=text, message_type=MessageType.TEXT, source=source) diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index 2bc5143d242..24055072fff 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -117,7 +117,8 @@ 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"), - guild_id=src.get("guild_id"), + # 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")), parent_chat_id=src.get("parent_chat_id"), message_id=src.get("message_id"), # Authentic upstream-trust signal: this event arrived over the diff --git a/gateway/session.py b/gateway/session.py index 342f0f11338..905de41e622 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -110,7 +110,14 @@ class SessionSource: user_id_alt: Optional[str] = None # Platform-specific stable alt ID (Signal UUID, Feishu union_id) chat_id_alt: Optional[str] = None # Signal group internal ID is_bot: bool = False # True when the message author is a bot/webhook (Discord) - guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope + # Platform-neutral SCOPE discriminator (Discord guild / Slack workspace / + # Matrix server). Drives server/workspace isolation + the relay δ/ε/ζ gate. + # Wire migration (D-Q2.5): `scope_id` is the canonical name; `guild_id` is a + # deprecated legacy alias kept during the cross-repo dual-read/dual-write + # overlap. Both are written by to_dict and read by from_dict (scope_id wins); + # the `guild_id` alias is dropped in a follow-up once both repos deploy. + scope_id: Optional[str] = None + guild_id: Optional[str] = None # @deprecated legacy alias for scope_id (D-Q2.5) parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react) role_authorized: bool = False # True when adapter granted access via role (not user ID) @@ -133,6 +140,16 @@ class SessionSource: # forge it across the wire or have it restored from persistence. delivered_via_upstream_relay: bool = False + def __post_init__(self) -> None: + # D-Q2.5 dual-field reconciliation: `scope_id` is canonical, `guild_id` + # is the deprecated alias. Mirror whichever was provided onto the other + # (scope_id wins on conflict) so internal readers of EITHER field see the + # same value during the cross-repo wire migration overlap. + if self.scope_id is None and self.guild_id is not None: + self.scope_id = self.guild_id + elif self.scope_id is not None: + self.guild_id = self.scope_id + @property def description(self) -> str: """Human-readable description of the source.""" @@ -169,8 +186,14 @@ class SessionSource: d["user_id_alt"] = self.user_id_alt if self.chat_id_alt: d["chat_id_alt"] = self.chat_id_alt - if self.guild_id: - d["guild_id"] = self.guild_id + # D-Q2.5 dual-write: emit BOTH the canonical `scope_id` and the + # deprecated `guild_id` alias (mirrored in __post_init__) so a connector + # on either side of the migration resolves the scope. Drop `guild_id` + # in the follow-up once both repos are on `scope_id`. + scope = self.scope_id if self.scope_id is not None else self.guild_id + if scope: + d["scope_id"] = scope + d["guild_id"] = scope if self.parent_chat_id: d["parent_chat_id"] = self.parent_chat_id if self.message_id: @@ -192,7 +215,9 @@ class SessionSource: chat_topic=data.get("chat_topic"), user_id_alt=data.get("user_id_alt"), chat_id_alt=data.get("chat_id_alt"), - guild_id=data.get("guild_id"), + # D-Q2.5 dual-read: prefer the canonical `scope_id`, fall back to the + # deprecated `guild_id` alias (a peer not yet migrated still sends it). + scope_id=data.get("scope_id", data.get("guild_id")), parent_chat_id=data.get("parent_chat_id"), message_id=data.get("message_id"), profile=data.get("profile"),