refactor(relay): adopt scope_id wire key (guild_id → scope_id dual-read/write) (#55289)

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).
This commit is contained in:
Ben Barclay 2026-06-30 11:16:53 +10:00 • committed by GitHub
parent f3d2dfbec6
commit 3a55f66602
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 47 additions and 16 deletions

View file

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

View file

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

View file

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

View file

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