Relay ↔ Connector 契約(v1、実験的)
目次
- 1. ハンドシェイク
- 2. CapabilityDescriptor(ハンドシェイクのペイロード)
- 3. 受信: MessageEvent のエンベロープ
- 受信のトランスポート(HTTP ではなく WS の逆方向チャンネル)
- 3.1 パススルー面の転送(§5.1)
- SessionSource のフィールド(通信上の表面)
- プラットフォームごとの SessionSource の識別子
- bot の ID とテナントの区別(単一 bot への集約、付録 A)
- 書いた人を先に見る解決と、アカウント連携(DM)の経路(Phase 7)
- 3.2 アイドル移行とバッファー切り替えの基本機能(§5.3)
- 3.3 起こすための通知(§5.2)
- 3.4 将来のゼロスケールの振る舞い層が負う義務
- 4. 送信: アクションの一覧
- 5. 割り込み(/stop)のルーティング
- 6. 信頼の境界と署名付き本文の扱い(A2)
- 6.1 チャンネルの認証(connector⇄gateway のリンクそのもの)
- 7. インスタンスごとの配送と管理面(Phase 6)
- 7.1 配送のゲート(connector 側、参考情報)
- 7.2 管理用のルート(connector 側、認証あり)
- 7.3 関連性の方針の申告(gateway の責任)
- 8. gateway 側のプラットフォームの振る舞いの設定(エンタープライズ)
- 9. バージョン管理の方針
> ステータス: 実験的です。実際の Class-1 プラットフォーム(Discord と Telegram)の少なくとも2つで > 検証が済むまで、この契約は非推奨期間を設けずに変わることがあります。実験段階での変更は > 追加のみで、contract_version によって区切られます。互換性を壊す変更では、両方のリポジトリを同時に更新します。
この文書は、Hermes gateway(Python、 gateway/relay/)と connector(Node/TypeScript、 NousResearch/gateway-gateway)のあいだの正式なインターフェースです。connector を実装する人は、まずこのファイルを 読んでください。
gateway は汎用の RelayAdapter を動かし、connector へ外向きに接続します。 ハンドシェイクで CapabilityDescriptor を受け取り、そのあとはターンごとの双方向 WebSocket 上で、正規化された MessageEvent(受信)とアクション(送信)をやり取りします。gateway は、自分の前段にどの具体的なプラットフォームがあるかを知りません。 プラットフォーム固有のソケットや ID の処理は、すべて connector が受け持ちます。
1. ハンドシェイク
- gateway がトランスポートを開きます(
connect)。 - gateway が
handshake()を呼び、connector がCapabilityDescriptor
(2章)を返します。これは、このアダプターのインスタンスが前段に置くプラットフォームを説明するものです。
- gateway は descriptor をもとにアダプターを設定し(文字数上限、長さの単位、
ドラフト・編集・スレッド・markdown の対応状況)、受信ハンドラーを登録します。
- その後、connector は受信イベントを流し、送信アクションを受け付けます。
contract_version(現在は 1)は descriptor に含まれます。gateway は descriptor の未知のフィールドを無視し(前方互換)、省略された任意フィールドは 既定値で埋めます。
2. CapabilityDescriptor(ハンドシェイクのペイロード)
JSON オブジェクトです。正本は gateway/relay/descriptor.py です。
| フィールド | 型 | 必須 | 意味 |
|---|---|---|---|
contract_version |
int | はい | 契約のバージョン(同じバージョン内では追加のみ)。 |
platform |
string | はい | プラットフォーム名(例: "discord"、"telegram")。 |
label |
string | はい | 人が読むためのラベル。 |
max_message_length |
int | はい | 文字数上限。gateway は MAX_MESSAGE_LENGTH として公開します。0 のときは 4096 として扱います。 |
supports_draft_streaming |
bool | はい | ネイティブのドラフトストリーミングによるプレビューに対応しているか。 |
supports_edit |
bool | はい | 編集によるストリーミングが可能か。false の場合、利用側は区切りごとに1メッセージを送る形に落とします。 |
supports_threads |
bool | はい | create_handoff_thread を使えるか。 |
markdown_dialect |
string | はい | "plain"、"markdown_v2"、"discord" など(supports_code_blocks を決めます)。 |
len_unit |
string | はい | "chars"(組み込みの len)か "utf16"(Telegram の UTF-16 コードユニット)。 |
emoji |
string | いいえ | 表示用の絵文字(既定は 🔌)。 |
platform_hint |
string | いいえ | システムプロンプトに入れるプラットフォームのヒント。 |
pii_safe |
bool | いいえ | セッションの説明から個人情報を伏せるか。 |
supports_context |
bool | いいえ | このプラットフォームで、宛てられたターンの周囲にあるチャンネルやグループの文脈を connector が提供できるか(Model A はオンデマンドで履歴を取得する方式 — Discord/Slack/Matrix。Model B は受動的にバッファーする方式 — Telegram/Signal/WhatsApp)。既定は false で、その場合は受信イベントに context が付きません。§3 を参照してください。 |
supports_inchannel_continuable |
bool | いいえ | プラットフォームがフラットで続きを書ける cron の表示面を持てるか(ネイティブ Slack の cron_continuable_surface: in_channel)。概要はチャンネルや DM のトップレベルに投稿され、普通に返信するとフラットな (platform, chat_id, None) セッションでジョブが続きます。既定は false で、その場合 gateway のスケジューラーは安全側のスレッドモードに倒れます(D6 ゲート)。古い connector では今までどおりスレッドで動きます。 |
supports_block_formatting |
bool | いいえ | gateway が send/edit フレームに metadata.format_hints を付けたとき、このプラットフォームの送信側が生の markdown からブロック単位の書式を描画できるか(Slack では表・リスト・コードにネイティブの markdown ブロックを使い、mrkdwn テキストは代替として残します)。既定は false で、その場合 gateway はヒントを付けないので、古い connector にこのメタデータが届くことはありません。 |
supported_ops |
string[] | いいえ | op 単位の機能の申告です。このプラットフォームについて connector の送信側が実際に実装している送信 op の名前を並べます(例: ["send", "edit", "typing", "follow_up", "get_chat_info"])。無いか空なら、connector はこのフィールドより前のもので、gateway は従来の op の組(send/edit/typing/follow_up)を前提にします。新しい op は、明示的に申告されたときだけ使われます。 |
ほとんどのフィールドは gateway にある既存の PlatformEntry を写したものです。 実行時にしか決まらないフィールド(len_unit、supports_*、markdown_dialect)は、 動いているプラットフォームアダプターの機能メソッドから取ります。
3. 受信: MessageEvent のエンベロープ
connector は、プラットフォームから届く各イベントを MessageEvent (gateway/platforms/base.py)に正規化して gateway に渡します。受信は、 gateway 側から張った外向きの /relay WebSocket 上で届きます(下のトランスポートの 注記を参照)。connector は、gateway がすでに接続したソケットに inbound フレームを流し込みます。gateway は 埋め込まれた SessionSource から build_session_key() でセッションのキーを作ります。そのため、正しい識別子を埋めることが、 connector が負う正確性の責任の中でいちばん重いものです。
受信のトランスポート(HTTP ではなく WS の逆方向チャンネル)
gateway は、ハンドシェイク、送信アクション(§4)、自身の /stop の送出(§5)のために、 connector の /relay WebSocket へ外向きに接続します。受信は 同じソケットを逆向きに流れます。connector は inbound フレーム(§5 では interrupt_inbound)を gateway の外向き WS に流し込みます。 gateway 側に受信用の HTTP エンドポイントはありません。gateway は受信用のポートを公開する必要がなく、 ホスティング環境では公開すること自体ができません。すべてのやり取りは gateway が 始めた接続の上を流れます。
複数インスタンスでのルーティング。 プラットフォームのソケットを持つ(つまり受信イベントを生み出す)connector のインスタンスは、 一般に gateway が外向き WS を接続したインスタンスとは別です。そこでイベントを生み出したインスタンスは、 テナントをキーにして connector 内部の relay bus(Redis pub/sub、 src/core/relayBus.ts の RelayBus)へイベントを流します。connector の各インスタンスはこれを購読し、 そのテナントについて自分のローカルにあるセッションへメッセージを振り分けます (RelayServer.routeBusMessage)。gateway のソケットを実際に持っている1つのインスタンスが届け、 そのテナントのローカルセッションを持たないインスタンスは何もしません。 つまりインスタンス間の配送はクラスター内の Redis を1回経由するだけで、公開された HTTP 呼び出しではありません。
フレーム(connector → gateway、WS 上):
{"type":"inbound", "event": <MessageEvent>, "bufferId"?}{"type":"interrupt_inbound", "session_key", "chat_id"}(§5){"type":"passthrough_forward", "forward": <PassthroughForward>, "bufferId"?}(§5.1)
受信に付くチャンネルの文脈(設計 relay-channel-context)。 送信元プラットフォームの descriptor が supports_context(§2)を申告していて、チャットが 複数人のもの(chat_type が group/channel/thread/forum のいずれかで、dm は含まない)なら、 connector は受信する MessageEvent に、任意で追加の2つのフィールドを付けてもかまいません。
context: 周囲のメッセージを読み取り専用で並べた配列です(同じチャンネル、古い順から
新しい順)。connector が取得した(Model A)か バッファーした(Model B)、近くにある宛先指定のない会話です。参照専用で、エージェントを起動することはありません(起動するかどうかは、 宛てられたイベントだけをもとに connector 側ですでに決まっています)。 gateway はこれを MessageEvent.channel_context に描画します(履歴の後埋めと同じ、読み取り専用の 注入経路です)。
context_error: bool です。プラットフォームは文脈に対応しているのに
取得やバッファーに失敗し、connector が空の context で処理を続けたときに true になります (観測用の目印で、connector 側では配送の span に出ます)。
どちらも無ければ、今と1バイトも変わりません。これらを送らない connector、 dm、文脈に対応しないプラットフォームでは、channel_context は作られません。
PassthroughForward は、パススルー面へ転送されたリクエストの通信上の形です (Class-2/3 の webhook — Discord のインタラクション、Twilio): {platform, botId, method, path, headers: [[k,v],…], bodyB64, profile?}。profile は任意です。 Team-Gateway のインタラクションで NAS が対象のプロファイルを解決したときに connector が付けます。 省略すると(プロファイルが1つの gateway)、従来どおり既定の agent:main セッション名前空間へ 振り分けられます。これは inbound フレームの SessionSource がすでに持っている profile フィールドと同じ扱いです(#60586)。本文は base64 でエンコードされるので、任意のバイト列が改行区切り JSON のトランスポートを通っても壊れません。 gateway は base64 をデコードして、 connector が転送したバイト列そのままに戻します(connector は入口ですでに プロバイダーの署名を検証し、共有 ID の認証情報を取り除いています — §6。そのため gateway が処理し直すのはトークンを含まない無害化済みの本文で、それに対する動作は トークンを使わない follow_up 経路で行います)。§3.1 を参照してください。
信頼。 WS のアップグレードは gateway ごとのシークレットで認証されます (§6.1)。そのためチャンネルは端から端まで信頼でき、受信フレームを個別に HMAC 署名することはありません(認証済みのソケットが、古い HTTP 経路で必要だった 配送ごとの送信元の証明を兼ねます)。relay bus を経由する区間は connector の信頼範囲の中にあります (lease、バッファー、capability の各ストアと同じです)。
> この契約の初期の草案では、受信を署名付きの HTTP POST で > gatewayEndpoint に届けていました(HttpGatewayDelivery と gateway 側の > inbound_receiver)。署名はテナントごとの配送キーによる HMAC でした。この方式では > すべての gateway が到達可能な受信 URL を公開する必要があり、公開 IP を持たないホスティング型の > gateway では不可能でした。上の WS 逆方向チャンネルがこれを置き換えます。 > テナントごとの配送キーは前方互換のためにプロビジョニング時に残していますが、受信にはもう > 使っていません。パススルー面(Discord のインタラクションや Twilio のような Class-2/3 の webhook)は、 > ACK 後の転送にしばらく gatewayEndpoint を使い続けていました。Phase 5 §5.1 でその転送も WS に移るので(上の > passthrough_forward フレーム)、ホスティング型の gateway が公開する受信面はゼロになり、切り替えが済んだ時点で > gatewayEndpoint は廃止されます。
3.1 パススルー面の転送(§5.1)
パススルー面は、プロバイダーが待つ遅延に厳しい ACK を connector の 入口で返し(例: Discord の遅延インタラクション応答は約3秒以内)、そのあと 本来のリクエストを gateway へ投げっぱなしで転送します。この 転送には返事が要らない(プロバイダーはすでに満足している)ので、HTTP POST ではなく、 inbound と同じ外向き WS 上を passthrough_forward フレームで流れます。gateway はデコードした リクエストを通常のエージェント経路で処理します(Discord のインタラクションは MessageEvent にデコードされ、 メッセージと同じように扱われます。返信は送信経路または follow_up 経路から出ていきます)。 スレッド内で送られたインタラクション(partial の channel.type が 10、11、12 のいずれか)は、 テキスト経路のスレッドメッセージと同じキーで扱われます。つまり chat_type: thread、thread_id = スレッドのチャンネル ID、 parent_chat_id = channel.parent_id です。connector がそのインタラクションに結び付ける権限(capability binding)も、 同じフィールドを導き出さなければなりません。そうしないと、あとから来る follow_up が、vault が一度も結び付けていない セッションを指してしまいます。生のインタラクションにはチャットのラベルがないため、gateway はテキスト経路がそのチャットについて 最後に運んだ chat_name / chat_topic をこれに与えます(観測した値として記録するので、再起動しても残ります)。 ユーザー名はインタラクション自体から決めます。サーバーのニックネーム、なければグローバル名、それもなければユーザー名の順です。 テキストメッセージで connector が付ける user_display_name も同じ順で決めなければなりません。そうしないと、2つの経路で 同じユーザーが別の名前になります。bufferId は、 転送がバッファーされたとき(Phase 5 §5.3 のバッファー専用への切り替え)に付き、 gateway は確実に引き渡したあとでこれに ack を返します。
SessionSource のフィールド(通信上の表面)
正本は gateway/session.py の SessionSource.to_dict() です。以下が、 gateway が通信上で受け付けるすべてのキーです。platform、chat_id、chat_type、 user_id、user_name、thread_id、chat_name、chat_topic は常に 含まれます(null のこともあります)。それ以外は値があるときだけ含まれます。
| フィールド | 型 | 常に送る | 意味 | |
|---|---|---|---|---|
platform |
string | はい | プラットフォーム名(descriptor の platform と一致します)。 |
|
chat_id |
string | はい | 会話の主 ID(チャンネルやチャット)。セッションキーの識別子です。 | |
chat_type |
string | はい | dm / group / channel / thread / forum。 |
|
chat_name |
string\ | null | はい | 人が読むためのチャット名。 |
user_id |
string\ | null | はい | メッセージを書いた人の ID。セッションキーの識別子です。 |
user_name |
string\ | null | はい | 書いた人の表示名。 |
thread_id |
string\ | null | はい | スレッド内のときのスレッドまたはフォーラムトピックの ID。セッションキーの識別子です。 |
chat_topic |
string\ | null | はい | チャンネルのトピックや説明(Discord、Slack)。 |
user_id_alt |
string | いいえ | プラットフォーム固有の安定した別 ID(Signal の UUID、Feishu の union_id)。 | |
chat_id_alt |
string | いいえ | チャットの別 ID(例: Signal のグループ内部 ID)。 | |
scope_id |
string | いいえ | プラットフォームに依存しないスコープの識別子です。Discord のギルド、Slack のワークスペース、Matrix のサーバーにあたります。Discord/Slack でスコープを分離するには必須です。 セッションキーの識別子です。(D-Q2.5 の通信移行で正式名になりました。) | |
guild_id |
string | いいえ | 旧名の別名で、connector はもう読みません。 D-Q2.5c 以降、connector が読み書きするのは scope_id だけです。gateway のエージェント全体で使う SessionSource.to_dict() は、relay 以外のセッション保存のためにまだ guild_id を出力します(scope_id と同じ値)。そのため通信上に現れることはありますが、connector は無視します。これに頼らないでください。 |
|
parent_chat_id |
string | いいえ | chat_id がスレッドを指すときの親チャンネル。 |
|
message_id |
string | いいえ | きっかけになったメッセージの ID(ピン留め、返信、リアクション用)。 |
> is_bot(書いたのが bot や webhook かの分類)は gateway 側の > dataclass にはありますが、v1 では意図的に通信に載せていません。 > to_dict() に含まれないためです。まずここと to_dict() に追加されるまで(追加によるバージョンアップ)、 > connector の SessionSource に加えないでください。
プラットフォームごとの SessionSource の識別子
| プラットフォーム | chat_id | chat_type | user_id | thread_id | scope_id |
|---|---|---|---|---|---|
| Discord | チャンネル ID | dm/group/thread |
書いた人の ID | スレッドのチャンネル ID(スレッドの場合) | ギルド ID(サーバーを分離するために必須) |
| Telegram | チャット ID | dm/group/forum |
from の ID | フォーラムトピックの ID(フォーラムの場合) | — |
Discord の guild_id を間違えると、2つのサーバーが1つのセッションに混ざります。 これが深刻度 High のリスクの筆頭です。gateway の build_session_key() が 適合性の基準です。ある SessionSource に対して、connector の正規化は Python のアダプターが作るのと同じキーを作らなければなりません。(Phase-1 のスタブテストは、 既知の入力から既知のキーが出ることを確かめています。)
bot の ID とテナントの区別(単一 bot への集約、付録 A)
エンベロープは、発信元の bot の ID をテナントとは別の フィールドとして持ちます。テナントは、イベント自身の識別子(Discord の guild_id、Telegram の chat_id、webhook のパスやサブドメイン)から決めます。 どのトークン、ソケット、プロセスが届けたかから決めることはありません。これにより、共有する1つの bot が多数の テナントの前段に立てます(Phase 6)。既存のフィールドに意味を詰め込む必要もありません。
書いた人を先に見る解決と、アカウント連携(DM)の経路(Phase 7)
Phase 7 では、共有 bot に対してユーザーが自分で1人ずつ登録できる仕組みが加わります。これにより、 振り分けられる受信メッセージのインスタンスを*どの*識別子で決めるかが変わり、 ユーザーが自分のアカウントを結び付けるための管理経路も加わります。
書いた人を先に見る解決(複数テナントのギルドの規則、D-7.2)。 1つの Discord ギルドに多数のテナントが入ることがあります。メンバーがそれぞれ自分の エージェントに連携している場合です。そのため connector は配送先のインスタンスを、 認証済みの書いた人の結び付け(user_instance_binding、resolveByUser により (tenant, platform, platform_user_id) をキーにする)から決めます。ギルドからインスタンスへの ルートでは決めません。具体的には次のとおりです。
- 連携済みのユーザーが書いて振り分けられたメッセージは、そのユーザーの
インスタンスにだけ届きます。同じギルドにいる2人目の連携済みユーザーが 別のインスタンスで扱われていても同じです(それぞれ自分のインスタンスにだけ届きます)。
- 未連携のユーザーが書いたメッセージは、どのインスタンスにも解決されず
捨てられます(安全側に閉じる — ギルドにいる他のテナントへ一斉に流すことはありません)。
- 使う書いた人の ID は、観測したイベントに載っている本物の
user_idです。
上で説明した SessionSource.user_id と同じもので、 gateway が主張した値や管理フレームに載った値を使うことはありません。
これは、connector が WsGatewayDelivery で強制している user_id ごとの持ち主限定の振り分けです(gateway 側の複数テナントギルド E2E ドライバー gateway_multitenant_guild_driver.py が、リポジトリをまたいだ基準になります)。
アカウント連携(DM)の経路。 ユーザーは 使い捨てのコードでアカウントをインスタンスに結び付けます。コードは共有 bot に DM を送って使います。
- 持ち主が Portal(またはセルフホストの CLI)から連携を始めます。
connector は、認証済みのインスタンスに対して有効期間の短い連携コードを発行します (POST /manage/link。instanceId は呼び出し元の principal から取ります — NAS が署名した aud=agent:{instanceId} トークンか、インスタンス自身の gateway ごとの シークレットです。リクエスト本文から取ることはありません)。
- ユーザーは、結び付けたいアカウントから共有 bot に
/link <code>を
ダイレクトメッセージで送ります。
- connector の受信オブザーバーがその DM を消費し(どの
エージェントにも振り分けません)、観測した DM イベントに載っている本物の user_id を使って user_instance_binding を書き込みます。それ以降は、書いた人を先に見る解決によって、 そのユーザーのメッセージが結び付けたインスタンスへ振り分けられます。
連携の解除は connector が決めます。 インスタンスのプロビジョニングを解除すると (POST /manage/deprovision)、そのインスタンスへの書いた人の結び付けが消え(ユーザーがそこへ 解決されなくなります)、さらに gateway ごとのシークレットが失効します(そのソケットはもう 認証できず、次の WS アップグレードは 4401 で閉じられます)。それまで成功していたハンドシェイクのあとで 4401 の切断を受けた gateway は、これを最終的な失効として扱います。 再接続をやめ、relay プラットフォームを 無効として報告します(再試行できるエラーではありません)。一度もハンドシェイクに成功する*前*の 4401 は、 再試行できるものとして扱います(コールドスタートやまだプロビジョニングされていない競合であって、失効ではありません)。
3.2 アイドル移行とバッファー切り替えの基本機能(§5.3)
ゼロまでスケールするための基本機能です(振る舞いそのものではありません — ここには、スリープを決めたり マシンを停止したりするものは何もありません。これらのフレームは、後の作業で使われます)。gateway が いないあいだに届いた受信を失わずに、gateway が drain/アイドルへの移行に入れるようにするものです。 そのために connector がそのインスタンス分をバッファーし、再接続時に再送します。
フレームは3つあります(いずれも接続の認証済みインスタンス ID をキーにします — WS アップグレード時に保存済みのシークレット記録から読み、フレームの中で主張されることはありません)。
{"type":"going_idle"}(gateway → connector)— gateway の
既存の drain への移行の一部として出します(アダプターがソケットを 閉じる前に送ります)。connector に、このインスタンスをバッファー専用へ切り替えるよう求めます。
{"type":"going_idle_ack"}(connector → gateway)— connector が切り替えを終えたことを示します。
ライブ配送は止まり、以後このインスタンス宛ての受信は確実に バッファーされます。gateway はこの ack を受け取るまで処理を続けます(切り替えの隙間に届いたイベントは 失われずライブで届きます — bus と同じ、処理を始める前に SUBSCRIBE するという 順序の規律です)。ack を受け取ってはじめて、閉じても安全になります。
{"type":"inbound_ack", "bufferId"}(gateway → connector)— 再接続時に再送された、
バッファー済みの inbound 配送(bufferId を持つ)を確実に受け取ったことを示します。 connector はこれを受け取ってからバッファーのエントリーに ack します。これで 配送区間で重複のない drain が成り立ちます。drain の途中で落ちたインスタンスには ack されていない末尾だけが再送され、ack 済みのエントリーが再送されることはありません。
バッファーと drain。 切り替えているあいだ、connector は受信をライブで流す代わりに、 インスタンスごとの確実な配送区間バッファー(delivery:<instanceId>)に追記します。 gateway が再接続すると(新しく追加した再接続ループが、予期しない切断のあとに接続し直して ハンドシェイクをやり直します)、新しいハンドシェイクをきっかけに connector がその溜まった分を新しいソケットで順番どおり、ack を待ちながら drain し、 そのあと切り替えを解除してライブ配送を再開します。これは、Discord→connector の取り込み区間と同じ drainWithoutDup の仕組みを、 connector→gateway の配送区間に使い回したものです。全体を通して connector が決めます。gateway が 切り替えや drain をできるのは自分のインスタンスだけです。
> 対象外(後回しの振る舞い): drain を決める自律的なアイドルタイマー、 > 実際のマシン停止、NAS の停止中ヘルスモデル。この > 基本機能は「gateway が drain したら、relay はバッファー専用に切り替え、再接続時に > 欠落も重複もなく再送する」というものです。何が drain のきっかけになるかは対象外です。
3.3 起こすための通知(§5.2)
スリープと起床のループのもう半分で、停止中の gateway がバッファー済みの仕事が待っていることを どう知るかを扱います。これも基本機能で、ここにはマシンを停止するものは何もありません。 起こすための合図だけをつなぎ、将来のゼロスケールの振る舞い層が「バッファーされた ⇒ 起こす通知が送られた」を前提にできるようにします。
- 登録。 gateway は enroll またはプロビジョニングのときに起床 URLを登録します。
connector が GET で叩いて起こせる、到達可能な URL なら何でもかまいません(Fly の autostart のホスト名や、 ダッシュボードのホスト)。セルフホストでは hermes gateway enroll --wake-url <url>(または GATEWAY_RELAY_WAKE_URL / gateway.relay_wake_url)で指定します。マネージド/NAS では、 コンテナの環境変数として GATEWAY_RELAY_URL の横に書き込まれます。 /relay/provision の本文で wakeUrl として送られ、connector の シークレット記録にインスタンスごとに保存されます(gateway が主張する値ですが、範囲は安全に限られています — instanceId と同じ扱いです。組織やテナントはトークンで検証されるので、gateway が 起こす対象として登録できるのは自分のインスタンスだけです)。廃止された gatewayEndpoint とは別物で、配送先ではなく通知先です。
- 通知。 バッファー専用(アイドル移行済み)の宛先に、最初の
バッファー済みイベントが届くと、connector はそのインスタンスが登録した wakeUrl に ペイロードなし・署名なしの GET を直接送ります(NAS を介しません — relay は NAS から独立したままです)。テナントのデータも受信内容も含まず、「バッファー済みの仕事があるので 再接続してください」と伝えるだけです。テナントの権限は、gateway が接続し直したときに通常の方法 (認証済みの WS アップグレード)で確立し直されます。そのため起床 URL が漏れたり 推測されたりしても、最悪の場合に起きるのは、そのインスタンス自身の不要な再接続だけです。 インスタンスごとに流量が制限され(イベントごとではなく、クールダウンの期間ごとに1回)、 できる範囲での送信です。通知が失敗しても無視され、gateway は次に自分から 再接続したときにいつでも drain します。新しいフレームはありません。起こす通知は帯域外の HTTP GET で、 relay の WS メッセージではありません(そもそもソケットが落ちているからです)。
> 対象外(後回しの振る舞い): 実際のマシン停止(Fly の > autostop:"suspend")と、スリープを決める自律的なアイドルタイマー。この > 基本機能は「スリープ中のインスタンスにバッファー済みのイベントが来たら、その wakeUrl に通知する」というものです。 > 何がインスタンスをスリープさせるか(そして処理のために起こすか)は振る舞い層の役目です。
3.4 将来のゼロスケールの振る舞い層が負う義務
§3.2 と §3.3 は基本機能を提供します。この節は、別のゼロスケールの 振る舞いの作業が、それらを安全に使うために守らなければならない契約です。 その作業は 停止の*判断*、実際のマシン停止、プラットフォームとヘルスの モデルを受け持ちます(どれもここにはありません)。ただし、基本機能が前提にしている次の保証は 必ず守らなければなりません。
- インスタンスが停止しうる状態になる前に
wakeUrlを登録すること。
wakeUrl が登録されていない停止中のインスタンスは、何も返ってこない穴になります。バッファーされた 受信が通知を起こさないので、ほかの何かが再接続させるまで、自分宛ての通信を寝過ごします。 振る舞い層は、停止を許す前提条件として、到達可能な起床先が登録されていること (セルフホストは --wake-url、マネージドは書き込み済み)を必ず確かめます。 マシンの停止中に到達できない起床 URL(例: 前段にプラットフォームの autostart がないまま、 停止中のマシンそのものを指している)は、無いのと同じです。
- ソケットを閉じたり停止したりする前に、
going_idleで drain し、going_idle_ackを待つこと。
ack されていない切り替えが進行中のまま停止してはいけません。 ack は、このインスタンスへの配送がバッファー専用になったことを connector が確認したものです。 going_idle を送ったあと、ack が来る前に停止したマシンは、 切り替えと競合して届いた受信を落とすことがあります。gateway はすでに ソケットを閉じる処理を ack 待ちにしています(Q-5.3c)。停止の手順は、きれいな drain が 終わった*あと*に置かなければならず、drain と競合させてはいけません。
- 停止の前提条件として、新しく追加した再接続ループを動かしたままにすること。
起床から drain までの契約は「通知 ⇒ gateway が接続し直す ⇒ connector が 再接続のハンドシェイクで drain する」です。再接続ループが無効だと、通知が届いても マシンは接続し直さず、バッファーは取り残されます。振る舞い層は、 起床しても relay のトランスポートが再接続しないインスタンスを停止してはいけません。
- ヘルスモデルでは、停止中を故障と区別すること(Q-5.3b)。 停止中のインスタンスは
健全に眠っているのであって、壊れているのではありません。ヘルス監視の層は両者を必ず区別し (例: プラットフォームのマシン状態を使う)、停止中のインスタンスを不健全として 再起動したり、警報を出したり、回収したりしないようにします。そうすると停止の意味がなくなり、 起床や drain と競合することもあります。
- 起こす通知はできる範囲での送信で、流量も制限されている — ちょうど1回届くことや、すぐ起きることを前提にしないこと。
インスタンスごとにクールダウンの期間あたり最大1回で、 失敗した通知は無視されます。振る舞い層は、通知を 確実で即時の合図として頼ってはいけません。正しさの根拠は、あくまで「gateway は 次に再接続したときにいつでも drain する」ことです。念のための二重の起床(例: 定期的に 再接続もするジョブ)を入れるかは、基本機能ではなく振る舞い層が決めることです。
- **本当にアイドルなときだけ停止すること — アイドルかどうかは connector から観測できるもので決め、
gateway の推測で決めない。** 何をアイドルとみなすか(進行中のターンがなく、N 分間受信がない)は 振る舞い層の方針ですが、既存の drain の仕組み(gateway_state の running→draining)と 組み合わせなければならず、relay だけの別のアイドル経路を作ってはいけません — §3.2 が going_idle に課しているのと同じ統合上の制約です。
- **メッセージの接続がすべて relay 越しのときだけ停止すること — しかも起動時だけでなく、
停止しようとするその時点で確かめ直すこと。** 直接つながっているプラットフォーム (Photon iMessage の gRPC ストリーム、BlueBubbles、gateway 自身から張ったボットトークン)は、 connector が緩衝も起床もできないソケットを掴んでいるので、そのまま停止すると受信を失い、 二度と起きません。この判定は、有効になっている起動プロファイルのプラットフォームと、 受け持っているすべてのプロファイル(gateway.multiplex_profiles の従側も含む)の 生きているアダプターを数えます。そしてアイドル監視は休止に入る前に毎回これを問い直すので、 起動後に立ち上がった直接接続のアダプター(プロファイルの再調整)があれば、 インスタンスは起きたままになります。
これらは振る舞い層が基本機能に対して負う保証です。基本機能が振る舞い層に対して 負うのは、§3.2/§3.3 ですでに定めたものだけです(going_idle による切り替え、 インスタンスごとの確実なバッファーと ack を待つ再接続時の drain、そして切り替え済みインスタンスの 最初のバッファー済みイベントでの通知)。
4. 送信: アクションの一覧
gateway はアクションの辞書を渡してトランスポートを呼びます。正本は gateway/relay/transport.py と gateway/relay/adapter.py です。
op |
フィールド | 結果 |
|---|---|---|
send |
chat_id、content、reply_to?、metadata? |
{success: bool, message_id?, error?} |
edit |
chat_id、message_id、content、metadata? |
{success: bool, error?} |
typing |
chat_id、content?、metadata? |
{success: bool} |
follow_up |
session_key、kind、content、metadata? |
{success: bool, message_id?, error?} |
send_media |
chat_id、media_kind、source_url、content?(キャプション)、filename?、reply_to?、metadata? |
{success: bool, message_id?, error?} |
prompt |
chat_id、prompt_kind、prompt_id、content(質問文)、options[]{id,label,style?}、timeout_s?、reply_to?、metadata? |
{success: bool, message_id?, error?} |
react |
chat_id、message_id、emoji、remove?、metadata? |
{success: bool, error?} |
thread_create |
chat_id(親)、thread_name、message_id?(起点)、metadata? |
{success: bool, thread_id?, error?} |
thread_rename |
chat_id(親)、message_id(スレッドの ID)、thread_name、only_if_current_name?、metadata? |
{success: bool, error?} |
get_chat_info(chat_id) は別に中継される呼び出しで、少なくとも {name, type} を返します。
metadata.profile(多重化の往復)。 チャットを宛先とする送信フレームはすべて、 metadata に、gateway が受信時に取り込んだテナントの識別子(scope_id、user_id)を載せます。 多重化された gateway では、そのチャットの受信を connector が振り分けた先の Hermes の profile も併せて載せます。 follow_up のフレームは、プロファイルを session_key の名前空間に埋め込んで運びます。 connector は、そのチャットまたはやり取りについて次に送る passthrough_forward / inbound に、 同じ profile を必ず打刻しなければなりません。そうすることで、スラッシュコマンドのあとに押された Discord のボタンが、同じプロファイルのセッションに届きます(gateway/relay/adapter.py::_with_scope、send_follow_up)。 プロファイルが 1 つだけの gateway がこのキーを出すことはありません。フレームは 1 バイトも変わりません。
send_media(Phase 2 のメディア送信)。 メディアは参照の形で通信を渡ります。 source_url は、(a) gateway が事前に POST {connector}/relay/media でアップロードした connector 上の再ホスト(本文は生のバイト列、ヘッダーは Content-Type と 任意の X-Media-Filename、認証は gateway ごとの HMAC bearer — WS アップグレードと同じトークンの 方式です。応答の {id, size} から参照 {connector}/relay/media/{id} を作ります)か、(b) connector が直接ダウンロードする公開の http(s) URL(例: fal.media で生成したもの)のどちらかです。media_kind は image / voice / audio / video / document のいずれかで、 プラットフォームごとのネイティブなアップロード経路を選びます(Telegram の sendPhoto/sendVoice/…、Discord の multipart の添付、Slack の外部アップロード、WhatsApp のメディアアップロードとメディア メッセージ)。キャプションは content に載り、プラットフォームの 通常の markdown の経路で描画されます。ネイティブのキャプションがないプラットフォームでは、続けてテキストを 送ります(connector 側で行います)。両方のルートとこの op は、supported_ops が send_media を申告しているときだけ有効です。古い connector にこの op が届くことはありません(gateway の メディア送信は、メディア対応以前のテキストでの代替に落ちます)。サイズ上限は 25 MB です (connector の mediaStore.ts の MEDIA_MAX_BYTES。超えたアップロードは 413 で拒否されます)。
受信メディア(Phase 2 のメディア受信)。 受信イベントの media_urls には、 取得できる参照が入ります。プラットフォームで公開されている URL はそのまま通します(Discord CDN)。 認証が必要だったり期限があったりするプラットフォームの URL(Telegram のファイル API、Slack の url_private、 WhatsApp Graph のメディア)は、connector 側でプラットフォームの 認証情報を使ってダウンロードし、{connector}/relay/media/{id} として再ホストします。プラットフォームの 認証情報が通信を渡ることはありません。再ホストの参照は、認証済みのどの gateway からも読めます (capability URL の考え方です。ID は 128 ビットの乱数で、 受け入れられたすべての受信者にすでに届けられています)。gateway は各 参照を gateway ごとの bearer でダウンロードし、ネイティブのアダプターと同じように、 エージェントにはローカルのファイルパスを渡します。再ホストには期限があります(TTL は約1時間)。遅延させず、 受け取ったらすぐにダウンロードしてください。並行する media 配列(同じ順序)が kind、mime、 size、filename、caption のメタデータを加えます。message_type は最初の 添付の種類(image/audio/document)を表します。
prompt(Phase 3 の対話)。 プラットフォームに依存しない1つの op で、 gateway でもっとも重要なやり取り(実行の承認、スラッシュコマンドの確認、 確認のための選択肢)をネイティブのコントロールで描画します。Discord のボタンコンポーネント、Telegram の インラインキーボード、Slack の Block Kit のアクション、WhatsApp のボタンメッセージ(選択肢が3つ以下)/ リストメッセージ(4〜10。10を超えると番号付きテキストの代替に落ちます)です。 prompt_kind(approval/clarify/choice)は見た目のヒントにすぎません。 prompt_id は gateway が発行し、connector からは中身の見えない値です。各 選択肢のコールバックのペイロードには、トークン hp1:<prompt_id>:<option_id> が入ります (64 バイト以下 — Telegram の callback_data の上限がすべての経路を縛ります。選択肢の ID は [A-Za-z0-9_.-] で 32 文字以下)。gateway は prompt の ID を、同じ文字種と長さの範囲で <per-process nonce>.<8 hex> として発行します。 メッセージの場合、connector は受け入れたインスタンスの組に絞って届けますが、パススルーの転送(Discord のボタン押下)は そのテナントの動いているすべての gateway セッションに送ります。そのため、gateway が自分の prompt を 兄弟の gateway のものと見分けるのに nonce を使います。style はプラットフォームごとに対応付けられます (primary/success/danger/secondary)。timeout_s は通信上は参考値で、 期限切れの判定は gateway 側で行います(保留中の prompt の登録簿が期限切れの エントリーを捨て、持ち主の gateway が「もう待っていません」という短い お知らせを返します)。
prompt_response(Phase 3 の受信)。 ユーザーのボタン押下は、 prompt_response: {prompt_id, option_id, label?, prompt_message_id?} を持つ 通常の受信 MessageEvent として戻ってきます。プラットフォームの custom_id がそのまま来ることは ありません。イベントの text は /{option_id} と同じ内容で、 message_type: "command" が付きます。そのため、このフィールドより前の gateway でも、押下を 捨てずに入力された返信として扱えます。このフィールドを理解する gateway は、 押下を必ず自分で消費します。自分が発行していない prompt の ID は、 同じ一斉送信が届いた兄弟の gateway のものです。 /{option_id} のテキストをチャットの経路に流すと、持ち主が1回 ack しただけなのに、兄弟の gateway がすべて 「Unknown command」と答えていました。送信元は、実際に クリックした本物のユーザーです(connector が観測したもの: Telegram の callback_query.from、Slack の block_actions.user、WhatsApp の messages[].from、Discord のインタラクションの member/user)。そのため gateway 側の認可のゲートは、入力された /approve と まったく同じようにボタン押下にもかかります。取り込みの経路は、Telegram の callback_query (ポーリング、allowed_updates を広げています。できる範囲で answerCallbackQuery を呼んで スピナーを止めます)、Slack の POST /slack/interactions(生のバイト列に対する HMAC とリプレイ ウィンドウ。/slack/commands と同じ扱い)、WhatsApp の対話型 button_reply/list_reply(webhook の正規化の分岐)、Discord の type-3 の コンポーネントインタラクション(パススルー §5.1 の無害化した転送。type-3 の入口での ack は DEFERRED_UPDATE なので、「thinking…」の返信は表示されません)です。ほかの 連携のボタンなど、無関係なコールバックのペイロードが prompt のイベントになることはありません。 Telegram/Slack/WhatsApp では connector で捨てられ、Discord の type-3 の 転送は従来どおり custom_id をテキストにした形のままです。
react(Phase 3 の ack のライフサイクル)。 bot 自身の emoji リアクションを message_id に付けたり外したりします。ネイティブのアダプターにある 👀→✅/❌ の 処理状況を示す ack を、relay 越しでも使えるようにするものです。通信上は Unicode の絵文字で、 Slack の送信側は Slack の名前の語彙(eyes、white_check_mark、…)に対応付け、 already_reacted/no_reaction を成功として扱います(冪等)。Telegram は setMessageReaction を使います(空の組 = 外す。Telegram は使える絵文字が決められているので、 文字によっては拒否されます — その失敗は構造化されて返り、gateway は リアクションを見た目だけのものとして扱います)。WhatsApp はリアクションのメッセージを送ります(空の 絵文字 = 外す)。契約上、リアクションはできる範囲での処理です。react が失敗しても、 ターンを失敗させてはいけません。
thread_create / thread_rename(Phase 4 のスレッドのライフサイクル)。 プラットフォームに依存しない 1組の op で、引き継ぎのスレッド、Telegram の DM/フォーラムのトピック、 LLM が付けるタイトルによる意味に沿った名前の変更をまとめて扱います。thread_create では、Discord はチャンネルのスレッド (type 11)を作るか、message_id があればメッセージを起点にしたスレッドを作ります。Telegram は createForumTopic を使います(トピックの ID が返ります)。Slack は名前付きの起点になる親 メッセージを投稿してその ts を返します(Slack のスレッドはメッセージを起点にします — 明示的に message_id の起点を渡すと、そのまま返されます)。作られた ID は SendResult.thread_id に載ります。thread_rename では、Discord はスレッドのチャンネルを PATCH し、 Telegram は editForumTopic を使います。only_if_current_name による上書き防止のガードは、 ネイティブのアダプターにある「人が付けた名前を優先する」という挙動で、 connector 側で強制します。Discord は先に今の名前を読み、一致しなければ何もしません(構造化された success:false)。Telegram にはトピック名を読む手段がないので、ガード付きの 名前変更は満たせず、安全側に失敗します(ガードなしの名前変更は実行されます)。Slack は thread_rename を申告しません(親メッセージのテキストは内容であって、 名前ではないためです)。WhatsApp はどちらも申告しません(スレッドがありません)。
自動スレッドの目印と、gateway が申告するコマンド一覧(Phase 4 の 受信/ハンドシェイク)。 connector の自動スレッド化の送信方針が Discord のスレッドを作ると、そのスレッドから後で届く受信イベントには source.auto_thread_created: true と source.auto_thread_initial_name が付きます。これは connector が観測した証拠で、gateway の意味に沿った名前変更の経路を有効にします (LLM のセッションタイトルが、ガード付きの thread_rename でスレッド名を変えます。 記憶はインスタンスごとなので、N>1 の構成で取りこぼしても、その経路が有効にならないだけです)。 gateway は、Discord の hello フレームで自分のスラッシュコマンドの組を申告することもできます(command_manifest: [{name, description, options?}])。 connector は Discord のグローバルなアプリケーションコマンドの登録を、 これに合わせて調整します(GET → 差分 → 一括 PUT で上書き。冪等で、間引きされ、 できる範囲での処理です — 登録に失敗してもハンドシェイクには影響しません)。コマンドは これまでどおりパススルー面から配送されます。一覧は、 Discord の登録内容を gateway の dispatcher が扱えるものと揃えておくためだけのものです。
受信の reply_to の補足情報(Phase 4)。 プラットフォームの返信は、 reply_to_message_id と一緒に reply_to: {text?, author?, is_own?} を持つことがあります。 ユーザーが引用したものを表し、connector がすでに手元に持っていたデータだけから埋めます (Discord のインラインの referenced_message、Telegram のインラインの reply_to_message、WhatsApp の context.from と、テキスト部分についてはインスタンスごとの 上限付きの受信テキストのキャッシュ)。フィールドがなければ、プラットフォームがそのデータを 持っていなかったということです。そのためにプラットフォームの API を追加で呼ぶことはありません。is_own は、 引用されたメッセージを前段の bot が書いたことを示します( is_reply_to_bot の関連性の目印と同じ証拠です)。gateway はこれらを、 ネイティブのアダプターが埋めるのと同じ MessageEvent の返信文脈のフィールドに対応付けます。
typing の content?(Slack のステータスの消去)。 typing フレームは通常 content を省きます。connector はプラットフォームの入力中の表示(Slack では 「is typing…」の Assistant ステータス、それ以外では1回きりの入力中表示)を出します。 content が空文字列のときは、明示的な*消去*の要求です。Slack では connector が Assistant のスレッドのステータスを "" にして消します。gateway は Slack(ステータスが残り続ける)に対してだけ消去を送り、1回きりの表示のプラットフォームには送りません。 contract_version 1 の範囲内での追加ですが、デプロイの順番に注意してください。 gateway-gateway #154 より前の connector は content を無視し、消去のフレームで逆に「is typing…」を *表示*してしまいます — connector を先にデプロイしてください。
follow_up(A2 の capability アクション)。 受信ペイロードの中には、 共有の bot の ID として動く認証情報を持つものがあります(例: Discord のインタラクションの follow-up トークン)。§6 のとおり、connector はそれを入口で取り除き、セッションをキーにして capability の保管庫に結び付けます。それが gateway に届くことはありません。これを 使うには、gateway はすでに入っているセッション(session_key)と capability の kind(例: discord.interaction_token)を指定して follow_up を出します。 トークンは渡しません。connector は保管庫から実際の値を取り出し、 テナントが一致するかを強制し(テナント B がテナント A の capability を使うことはできません)、 送出します。capability が無いか期限切れのとき、またはテナントが 一致しないときは success: false になります。gateway には再試行に使えるものが何もありませんが、これは設計どおりです(漏えいした gateway は capability の材料を何も持っていません)。正本は gateway/relay/transport.py(send_follow_up)と gateway/relay/adapter.py です。
5. 割り込み(/stop)のルーティング
- gateway → connector:
send_interrupt(session_key, reason?)は、ターンの途中の
/stop を外向き WS で送出します。connector は、その session_key を動かしている gateway のインスタンスへ必ず転送します(ルーティングの不変条件)。
- connector → gateway: ある
session_keyへの受信の割り込みは、
gateway の外向き WS に interrupt_inbound フレームとして届きます(§3 のトランスポートの 注記)。relay bus を通って、ソケットを持っているインスタンスへインスタンスをまたいで振り分けられ、 アダプターの on_interrupt(session_key, chat_id) によって 既存のセッションごとの割り込みの仕組みにつながり、そのターンだけを取り消します (ほかのターンには触れません)。
どちらの向きも gateway の外向き WS を使います。gateway→connector の /stop は その上で送出され、connector→gateway の割り込みは、正規化されたイベントとして同じ inbound の 逆方向チャンネルを流れます。
6. 信頼の境界と署名付き本文の扱い(A2)
暗号と ID の境界は connector だけです。gateway は何も検証し直しません。
webhook の署名(Discord の ed25519、Twilio の HMAC、WeCom の BizMsgCrypt)は 生のバイト列そのものに対して計算され、ペイロードによっては共有 シークレットで*暗号化*されています。connector は多数のテナントのために共有の bot の前段に立ち、すべての テナントのプラットフォームのシークレットを持っているので、次のことを行います。
- 入口で検証・復号します(シークレットがあるのはそこだけです)。
- ペイロードをテナント単位の
MessageEventに正規化します(§3)。 - 共有 ID として動く capability をペイロードから取り除き、セッションをキーにして
capability の保管庫に結び付けます(§4 の follow_up を参照)。
- 無害化した
MessageEventだけを転送します。生の署名付き本文は転送しません。
そのため gateway は、relay の経路ではプラットフォームの署名や暗号の検証を一切行わず、 正規化されたイベントを信頼します。これは gateway 側で強制された不変条件です (tests/gateway/relay/test_relay_sheds_crypto.py: relay の パッケージはプラットフォームの暗号処理を import も呼び出しもしません)。
なぜ「署名付き本文を1バイトも変えずに転送し、gateway に検証し直させる」方式にしないのか? その以前のモデルは、信頼できず使い捨てにされるテナントの gateway という前提では成り立ちません。
- Twilio の HMAC や WeCom の暗号を検証し直すには、gateway に
共有の署名シークレットを渡す必要があります。それ自体が漏えいであり、共有 bot では *テナントをまたいだ*漏えいになります。
- WeCom のペイロードは共有シークレットで暗号化されています。connector は振り分けるだけでも入口で
復号しなければならず、暗号文を転送すれば、やはり gateway にシークレットを 渡す必要が出てきます。
- Discord のインタラクションのトークンは、署名付き JSON 本文の中にあります。バイト列を保ったまま
認証情報だけを取り除くことはできません。両者は同じバイト列だからです。
そこで、バイト列の保存は意図的にやめています。connector は無害化したイベントを シリアライズし直し、gateway はそれを信頼します。これによりパススルー面と relay 面も一本化されます。どちらも「入口で検証する → 正規化したイベントを出す」で、違うのは トランスポートだけです。A2 の考え方の全体と connector 側の保管庫については、docs/capability-trust-boundary.md(connector のリポジトリ: gateway-gateway)を参照してください。
6.1 チャンネルの認証(connector⇄gateway のリンクそのもの)
A2 では connector だけがプラットフォームのシークレットを持ちますが、gateway は 顧客が管理し、インターネットに公開されていることもあります。そのため connector⇄gateway のチャンネル 自体も認証します。gateway は、enroll またはプロビジョニングで発行された gateway ごとのシークレット(hermes gateway enroll → connector の /relay/enroll、または マネージドのセルフプロビジョニング → /relay/provision)を持ち、これで外向き WS の アップグレードを認証します。複数のシークレットを検証リストに並べてローテーションできる HMAC-SHA256 の方式です (gateway 側: gateway/relay/auth.py、connector 側: src/core/relayAuthToken.ts)。
| 区間 | 認証情報 | 仕組み |
|---|---|---|
| gateway → connector の WS アップグレード | gateway ごとのシークレット | /relay のアップグレードに付ける Authorization の bearer ヘッダーです。トークンは base64url(payload:exp:sig) で、payload = gatewayId、sig = HMAC(payload:exp, secret) です。connector はこれを検証し、不一致・欠落・失効のときはアップグレードを拒否します(close 4401)。認証されたテナントは connector のストアから取り、hello フレームからは取りません。 |
connector → gateway の受信(inbound / interrupt_inbound フレーム) |
—(認証済みの WS に乗ります) | 受信は gateway がすでに認証を済ませた外向きソケットに流し込まれるので(§3)、メッセージごとの署名は要りません。テナントごとの配送キーは今も enroll/プロビジョニングの時に発行され、前方互換のために残していますが、受信の署名にはもう使っていません。 |
これはチャンネルの認証で、プラットフォームの暗号とは別物です。プラットフォームの暗号は、 relay の経路では今もすべて外しています(§6)。gateway はプラットフォームのシークレットを何も持たず、 gateway ごとのシークレットが認証するのは connector とのリンクだけです。脅威モデルの全体と、 enroll・ローテーション・緊急停止の設計は docs/connector-gateway-auth-design.md (connector のリポジトリ)にあります。
7. インスタンスごとの配送と管理面(Phase 6)
Phase 1〜5 では、connector を単一テナントの前段として扱います。あるテナント宛ての受信イベントは、 そのテナントの gateway のソケットへ広がって届きます。Phase 6 では配送を インスタンスごとにします。共有の bot が、1つのテナント(1つの Discord ギルド、1つの Telegram bot)の中で多数のユーザーやエージェントの前段に立っても、別の宛先に誤って届かないようにするものです。あわせて、エージェント(またはマネージドの Portal)が 誰が何を見られるか、何が関係あるかを申告するための小さな管理面が加わります。 これらはすべて connector 側にあります。gateway に新しく加わる 責任は、起動時に関連性の方針を申告することだけです(§7.3)。
7.1 配送のゲート(connector 側、参考情報)
connector は、受信イベントごとに、3つのフィルターを AND で組み合わせてどのインスタンスが受け取るかを決めます。 gateway はこれらを実装しません(connector の中で動きます)が、 gateway が前提にする配送の意味を定めるものです。
| 層 | 問い | 正本 |
|---|---|---|
| 持ち主 / スコープ ∧ principal | このインスタンスは、ここでこの書いた人を*見て*よいか? | ユーザーごとの user_id → instance の結び付け(持ち主の床)と、インスタンスごとの (guild, channel) のスコープ付与、そして owner-only / allow-list / any の principal の方針。 |
| 可視性の床 | インスタンスに結び付いた持ち主は、Discord でこれを実際に VIEW_CHANNEL できるか? |
Discord のライブの ACL(実効権限)で、安全側に閉じます。広すぎるスコープ付与を下方向に絞ります。 |
| 関連性 | 見てよい*として*、エージェントは反応すべきか? | §7.3 で申告する関連性の方針(宛先指定の要否、自由に応答するスコープ、bot の許可)。 |
この組み合わせは配送を絞る方向にしか働きません(deliver ⇔ authorized ∧ visible ∧ relevant)。持ち主の床は関連性の層を通りません(書いた人自身の メッセージは、必ずその人のインスタンスに届きます — 自分のエージェントに @メンションはしないからです)。 結び付けのないユーザーが書いたメッセージは、どのインスタンスにも届きません(安全側に閉じる)。 設計と不変条件の全体は connector のリポジトリ (NousResearch/gateway-gateway)にあります。この節は gateway から見た要約です。
7.2 管理用のルート(connector 側、認証あり)
connector は認証付きの管理用ルートを持ちます。これらは WS アップグレードと同じ 2通りの認証を使います。マネージドの NAS が署名した aud=agent:{instanceId} RS256 JWT か、gateway 自身の gateway ごとのシークレットによる bearer(§6.1 の make_upgrade_token)のどちらかです。どちらの場合も、connector は正式な {tenant, instanceId} を保存済みの記録から決め、リクエストの 本文からは決めません(本文で主張された instanceId は無視されます)。
| ルート | 目的 | ||
|---|---|---|---|
POST /manage/link |
プラットフォームのアカウントを認証済みのインスタンスに結び付けるための、有効期間の短いコードを発行します(/link <code> の流れ。connector は本物の user_id を受信イベントから読みます)。 |
||
POST /manage/scope、/manage/scope/release |
認証済みのインスタンスについて (guild, channel) のスコープを取得・解放します。1つのチャンネルを持てるインスタンスは最大1つです(重複しないことは主キーの制約で保証します)。 |
||
POST /manage/principal |
インスタンスの principal の方針(owner-only \ |
allow-list \ |
any)を設定します。 |
POST /manage/dm-default |
ユーザーの DM の既定のインスタンスを設定します(ユーザーが複数のインスタンスに連携しているときの DM の決め手)。 | ||
POST /relay/policy |
インスタンスの関連性の方針を申告します(§7.3)。 |
これらは connector が持つもので(管理面は gateway の エージェントの経路には含まれません)、gateway が呼ぶのは POST /relay/policy(§7.3)だけです。ほかは マネージドの Portal や hermes CLI から使います。
7.3 関連性の方針の申告(gateway の責任)
関連性の層(§7.1)は、gateway 自身の振る舞いの設定 (require_mention、free_response_channels、 {PLATFORM}_ALLOW_BOTS)とテナント単位で同じ働きをするものです。relay の配送にも同じ振る舞いが効くように、 gateway はそれらの設定をプラットフォームに依存しない方針に写し、 起動時に(gateway ごとのシークレットが解決されたあとで)POST /relay/policy に POST します。
本文(gateway/relay/__init__.py の relay_relevance_policy() → send_relay_policy()):
| フィールド | 型 | 写し元 | 意味 |
|---|---|---|---|
platform |
string | 前段のプラットフォーム(relay_platform_identity) |
この方針を適用するプラットフォーム。 |
requireAddress |
bool | require_mention |
持ち主以外のメッセージは、bot に @メンションするか返信しないと関係ありと扱われません。 |
freeResponseScopes |
string[] | free_response_channels |
requireAddress を免除するスコープ(チャンネル)の ID。§7.1 のスコープ付与と同じスコープの語彙です。 |
allowOtherBots |
bool | {PLATFORM}_ALLOW_BOTS ∈ {mentions, all} |
bot が書いたメッセージを受け入れるか(既定はオフ)。 |
認証は gateway ごとのアップグレードトークン(§6.1)なので、connector は 方針を認証済みのインスタンスに結び付けます。gateway が正本で、 起動のたびに申告し直します(丸ごとの置き換えで、プロビジョニング時の routeKeys の upsert と 同じく、自己修復します)。写した方針がすべて既定値なら、gateway は 何も送りません(connector で行が無いときの既定値とすでに一致するためです)。この POST は 失敗しても止まりません。失敗はログに残り、起動はそのまま進みます。関連性は 認可のゲート(§7.1)の上に重ねた最適化であって、起動の依存関係ではありません。 gateway に新しい受信面は加わらず、新しい認証情報もありません。 gateway ごとのシークレットと、/relay/provision と同じホストを使い回します。
> 関連性による破棄は、connector がゼロまでスケールしたエージェントを起こす前に行われます > (Phase 5)。そのため除外された会話でエージェントが起動することはありません。関連性は > 正確性のフィルターであると同時に、ゼロスケールのための主要な手段でもあります。
8. gateway 側のプラットフォームの振る舞いの設定(エンタープライズ)
エンタープライズ環境では、前段のプラットフォームの振る舞いを gateway 側の platforms.relay.extra.<platform> で設定します。そのプラットフォームの ネイティブな設定のうち、対応している一部です。ネイティブのプラットフォームのブロック(例: platforms.slack)は relay の経路では読まれません。connector はこれらの設定の*結果*を フレームのメタデータ(§4)として受け取り、機械的に実行するだけで、 プラットフォームの振る舞いの方針を自分では持ちません。
platforms:
relay:
extra:
slack:
reply_in_thread: true # default解決の順番: 入れ子の extra.<platform> オブジェクトが優先 → extra 直下の旧来のフラットなキーを代替として参照 → 既定値。正本は RelayAdapter._effective_reply_in_thread(gateway/relay/adapter.py)です。 値の変換はネイティブの Slack アダプターとまったく同じで、1/true/yes/on (大文字小文字を区別せず、前後の空白を除く)がオン、それ以外はすべてオフです。そのため YAML で引用符付きにした "false" は、真とみなされる文字列として読まれず、ちゃんと設定をオフにします。
現在の設定(Slack):
| キー | 既定値 | 効果 |
|---|---|---|
reply_in_thread |
true |
true: メッセージごとにスレッドを作ります。DM のトップレベルのメッセージごとに、それを起点にしたスレッドができます(ステータス、進捗、prompt、最終的な返信はすべてその metadata.thread_id を持ちます)。false: 流れていくフラットな DM です。送信経路のフレームはスレッドの起点を持たず(省略ではなく取り除かれます)、DM ごとに1つのセッションを共有します。 |
dm_top_level_threads_as_sessions |
true |
ネイティブと同じ挙動にするための逃げ道です(platforms.slack.extra.dm_top_level_threads_as_sessions と同じ)。true: メッセージごとにスレッドを作るモードで、DM のトップレベルのメッセージごとに別のセッションになるので、同時に来たメッセージが並列に処理されます。false: 返信はスレッドに置いたまま、セッションの区切りだけを省きます — DM 全体で1つの流れていくセッションになります(従来の steer/queue の扱い)。フラットモードでは効果がなく、常に1つの流れていくセッションのままです。 |
入力中表示やステータスのフレームは、きっかけのメッセージの ts の起点がわかっていれば常にそれを持ちます (動いていることの表示は、どちらのモードでも無条件です)。Slack のステータス行は スレッド単位で表示されるためです。フラットモードでは、送信側で起点を取り除くので、 ステータスの起点が返信の置き場所に漏れることはありません。ネイティブの キーの意味については Slack を参照してください。
スレッドの起点の解決は、テキスト(send)でもメディア (send_media)でも、すべての送信経路に1か所の関門 (RelayAdapter._apply_slack_thread_anchor)を通して適用されます。メディアのフレームは、 metadata.thread_id だけを見てスレッドに置く connector 側の同じ Slack 送信側から出ていくので、 添付の起点もテキストの返信とまったく同じように解決されます。 メッセージごとにスレッドを作るモードではメタデータに持ち上げられ、フラットモードでは取り除かれます。
変更は gateway の再起動で反映されます。connector 側での作業は要りません。
9. バージョン管理の方針
contract_versionは int です。実験段階では、追加の変更(新しい任意フィールド、新しいop)の
ときにだけ上げます。
- 互換性を壊す変更(フィールドの名前変更や削除、意味の変更)では、
両方のリポジトリを揃えて更新し、バージョンを上げる必要があります。
- connector の最初の PR には、実装の基準にしたこのファイルのコミット SHA を
記載します。