コンテンツにスキップ

申告型セッション同一性 (session_key)

翻訳について: 正本は英語版です。日本語版が古い場合は英語版を参照してください。

ステータス: 2.5.7b1 ラインへの提案。加算的かつ挙動保存 — 何も送らない呼び出し元に とっては、今日の挙動がバイト単位で変わりません。

1. 問題: プロセスはセッションではない

stdio トランスポートでは 1 プロセスが 1 クライアントを捌くため、プロセス global な 状態は構造的にセッションスコープになります。streamable-HTTP ではそうなりません。 サーバーは StreamableHTTPSessionManager(..., stateless=True) で走り、1 プロセスが 接続中の全クライアントに応答し、リクエストをまたいで生き残るセッションが存在しません。 config.shared_transport() は既にその条件に名前を与えており、呼び出し側が何度も同じ 問いを立てたために存在しています。

バグ台帳にある 2 件の欠陥は、同じ「軸の欠落」が 2 度現れたものです。そして両方とも、 正直さが許す範囲までしか修理されていません:

  • bug-151pause_persistence は素のプロセス global フラグです。あるクライアント の pause が、TTL が切れるまで他の全接続セッションの write を黙って no-op に 変えます。修理は pause / resume / status の応答に scope: "process" を足し、 per-session スコープを偽って示唆していた docstring を訂正しました。 per-session 状態は追加していません: 影響範囲は開示されただけで、除去されて いません。
  • bug-251 — 劣化 recall の advisory は「利用者に知らせよ」というフル runbook を プロセスごとに 1 回だけ発火します。そのため障害中に知らされるのは 1 セッション だけで、他のセッションは自分が見ていないメッセージへの続報を受け取ります。修理は 抑制を transport 単位に付け替え、なぜそれ以上進めなかったかを注記しています — per-session の抑制は「caller-supplied key が要る」と。health.py も同じことを 未来形で述べています。advisory_scope は「caller 由来の key がこの継ぎ目に届いた日」 に session になる、と。

3 つ目の面は既にこのパラメータを持っています。get_session_findingssession_key を受け取るのは、SuperAuditor 標準 §7 が「セッションを見分けられない実装はそう言え」と 要求するためで、その docstring は key が「正直さのフラグ以外は何も変えない」と認めて います。

本設計が想定する配備では、クライアントはこの同一性を帯域外で供給できません。環境変数は プロセス起動時に確定し、ヘッダ値は起動時に一度だけ展開され、トランスポートのセッション id は後続の呼び出しでサーバーへ返ってきません。同一性は呼び出しの引数、すなわち データとして運ぶしかない — これは CScheduler が自前の session_key を足す前に到達した 結論と同じです。

2. これが何であり、決して何になってはいけないか

session_key不透明で、クライアントが申告する分割ヒントです。サーバーはその バイト列に意味を与えません — 比較するだけで、それ以外は何もしません。

これは認証ではありません。どの呼び出し元も任意の文字列を送れます (他セッションの ものを含む)。クライアント別ケーパビリティの線とも OAuth とも直交します。あちらは呼び出しが 許されるかを決め、こちらは許された呼び出しがどのプロセス内バケットに落ちるかを決めます。

これは第 4 の分離軸ではありませんagent_id / project_id / channel はクエリが 誰のデータを読むかを選びます。session_key は呼び出しが誰のプロセス内状態に触れるかを 選びます。この key で DB の行がフィルタされることはなく、保存済みの記憶が到達可能に なったり不能になったりもしません。この線を最初に引くのは、次に必ず来る要望 — 「recall をこのセッションに絞りたい」 — が、記憶をそれが越えるために存在する境界の 向こうで読めなくしてしまうからです。それを望むなら、上記 2 件の欠陥とはまったく別の 論拠が要ります。

3. 継ぎ目

解決点は 1 つだけで、姉妹実装と同じ形です:

def resolve_session_key(declared: str | None) -> tuple[str, bool]:
    """Return (effective_key, declared)."""
    if isinstance(declared, str):
        stripped = declared.strip()
        if stripped:
            return stripped, True
    return TRANSPORT_KEY, False

受け取るのはリクエストではなく値です。本サーバーのツールレジストリは宣言済みパラメータを ディスパッチ前に検証・抽出するため、ハンドラはキーワード引数を受け取り、引数 dict を見る ことがありません (姉妹実装が dict から解決するのは、あちらのディスパッチが dict を渡すから です。継ぎ目は同じで、宿主が違います)。

  • 空白のみでない非空文字列が実効 key となり、declared は true になります。
  • 不在・空・空白のみは transport fallback に落ち、declared は false — 既存の全呼び出し元にとって今日の挙動そのままです。
  • 長さ制限も、書式検証も、strip() を超えるサニタイズもありません。値は比較される だけで、パースされず、SQL 識別子に展開されず、同一性の主張としてログにも残りません。

fallback はプロセス単位の定数 (TRANSPORT_KEY) です。stdio ではプロセスが 1 セッションなのでそれ自体が 既にセッションであり、streamable-HTTP では key なしの呼び出し元が全員落ちる 1 つの 共有バケットになります — つまり今日の世界そのものです。fallback がプロセスから セッションを導出しようとしないのは意図的です。共有トランスポートではプロセスの系譜は サーバー自身を指すのであって呼び出し元の誰でもなく、そこから導いた key は答えのように 見えて何も分割しません。

呼び出し元が fallback の値そのものを申告してそのバケットに入ることは当然できます。これは 塞ぐべき穴ではありません — key はヒントであり、ある key を送れる呼び出し元は別の key も 送れます。これによって守られているものは何もありません。

4. 何を分割するか — 完全な一覧

想定ではなく実測です。パッケージ内のモジュールレベルの可変値をすべて列挙し、プロセス global かつセッション形状であるものは以下の 2 つだけでした:

状態 現在 key 申告時
no-persist の pause (旧 no_persist._no_persist_until、現 session._pauses) プロセス全体で 1 つ key ごとに 1 エントリ
劣化 advisory の抑制 (health._advisory_emitted) プロセス全体で 1 つ key ごとに 1 エントリ

候補に見えた他のものは、いずれも候補ではありません:

  • vector モジュールの閾値・融合ゲート・beta のキャッシュは agent をキーにしており、 calibration authority も同様です。ある agent の較正はその agent のセッション群で 共有されるべきものです。
  • recall precision と profile の状態は DB に agent 単位で保存されています。
  • DB のロックがプロセス単位なのは、DB がプロセス単位だからです。

分割対象の 2 つはどちらもプロセスメモリ上にあります。テーブル変更なし、migration なし、 GC ジョブなし。 ここが姉妹実装との実質的な差です。あちらは分割対象が永続状態だった ため、3 つのテーブルに session_key 列とスキーマ migration が必要でした。

5. 段階

このパラメータは無料ではない (§6) ので、まず元が取れるところに入れます。

段階 1 — advisory の per-session 化と、pause の開示

session_key を受け取るツール: recall / recall_with_context / pause_persistence / resume_persistence / persistence_status

  • advisory が本当に per-session になります。 抑制が実効 key をキーにするため、 障害中は recall する各セッションがそれぞれ 1 回ずつフル runbook を受け取り、key を 申告した呼び出し元に対して advisory_scopeprocess ではなく session と答えます。 bug-251 の先送りされた半分はここで閉じます。
  • pause が所有者を開示します。 pause は自分を張った key を記録し、 persistence_status は「呼び出し元自身の key が張ったのか、他セッションが張ったのか」を 報告します。継承した pause で skip された write もそう言えます。write は依然として 全体が止まります — これは開示であって分離ではなく、doc がそれ以上を主張してはいけません。

段階 2 — pause の per-session 化

実装済み。 pause を参照する全ツールが自分の key を知るようになり、セッションは自分の write だけを止め、他のセッションには影響しません。session_key は、実際に pause を参照 する write ツール 16 本に threading されています (store / archive_episode / update_memory / lock_memory / unlock_memory / delete_memory / delete_episode / delete_agent_data / update_profile / calibrate_threshold / set_recall_precision / migrate_channel_axis / import_memories / merge_memories / check_health / deep_check)。

export_memories は本パラメータを受け取りません。本節の以前の版はこれを列挙して いましたが、このツールは pause gate を参照しないため、key は「そのツールでは決して効かない 説明文を全クライアントが読み込む」だけのものになります。上の一覧は計画が言ったことでは なく、コードが実際に行っていることです。

key なしの呼び出し元は 1 つのバケットを共有し続けるため、key なしの pause は key なしの 呼び出し元すべてを止め続けます — 特別扱いによってではなく、そのバケットが単に TRANSPORT_KEY であることによる構造的な挙動保存です。

実装は 2 つでなく 1 つ。 素直な形 — key なしバケットは vendored のプロセス global フラグに残し、申告 key 用にマップを足す — は 1 つの不変条件を 2 実装持つことであり、 誰も動かさない側が必ず drift します。そうではなく cpersona.session が全バケットの pause を持ち、vendored 側には本当に共有すべきものだけを残しました: TTL 定数と make_skipped_response です (後者の id sentinel と action-id の null 化 (bug-104) は、 コピーが 1 つだけであるべき不変条件です)。この線はテスト 2 本が保持します — 1 本は vendored のスイッチ呼び出しがパッケージ内に再出現したら落ち、1 本は本モジュールの TTL 引数処理を、置き換えた vendored の規則に対して固定します。再 vendor で規則が変わった時に、 黙って乖離するのではなく捕まえるためです。

開示フィールドがどうなったか。 段階 1 は「自分を黙らせている pause は自分のものか、 並列セッションのものか」に pause_owner_known / paused_by_self で答えていました。 段階 2 はその問い自体を消します — 他人の pause は自分に届かないので、フィールドが現れる 場面では常に自明に真になるからです。申告した呼び出し元に対しては両フィールドを廃し、 scope: "session" が代わりに意味を運びます。key なしの応答はキーごと不変で、 scope: "process" も含めてそのままです — プロセス上の key なし呼び出し元は依然として 1 バケットを共有しているので、これは真であり続けます。

キューに積まれた作業。 タスクキューは pause 中の write を捨てます。「利用者の ephemeral な意図は、それに先行して積まれた作業に優先する」ためです。キューの行は セッションを持たず — それを記録するのは保存列であり、下の「対象外」です — そこでキューは 「task id → 投入した key」のプロセス内マップを保持し、各タスクをその key で判定します。 プロセス再起動を生き延びた行は帰属を失い、transport バケットに fallback します。これは 正しい挙動です: 再起動をまたいで生き残るセッションも存在しないからです。

対象外

セッション単位の findings probe — 「このセッションが触って pending のまま残した記録」、 SuperAuditor 標準 §7 が想定し姉妹実装が持っているもの — はどのセッションが行を書いたかの 記録を要し、それは保存列と migration と保持期間の問題になります。本設計には含めません。 それができるまで get_session_findings は key なしの remote 呼び出し元に identity_shared: true を返し続け、他は何も変わりません。

6. パラメータ自身のコスト

session_key を受け取るツールは、その説明文をクライアントが毎セッション読み込むツール 一覧に載せます。これは key を一度も申告しない呼び出し元も含めた全員が払う固定費で あり、本設計が一気に threading せず段階に割った理由です。

本サーバーが広告するツール一覧 (シリアライズ後) の実測値。測り方は、live payload から session_key プロパティだけを削る方法です — マージコミットを親と差分する方法ではありま せん。後者は同じコミットに相乗りした無関係な説明文の変更をすべて混ぜ込みます:

ツール数 シリアライズ後の文字数
session_key なし 30 38,970
段階 1 後 30 41,719
差分 0 +2,749 (+7.1%)

説明文は 1 つを書いて全スキーマで共有しているので、上の数値は共有された 1 つのテキストで あって、長い文章の 5 コピーではありません。

本節の数値はすべて scripts/measure-tool-list.py で再現できます。指定したパラメータを live payload から削って値付けするスクリプトです。これが存在するのは、本節が「計測が次の段階を 決める」と約束しているからです — 誰も再実行できない約束は、記憶から引用される約束になります。

段階 2 は何に対して測られたか

段階 2 はこの数値を条件としており、選ぶ前に 4 つのアームを実測しました — 算術ではなく、 実際の payload を編集して測っています:

アーム 説明文の方針 シリアライズ後 key なし基準比
A 新たに key を持つ全ツールに共有の全文 50,372 +29.3%
B 新たに key を持つツールに短文 45,221 +16.0%
C 短文を全面採用し、既存の使用箇所も書き換え 43,706 +12.2%
D recall / recall_with_context 以外を短文に 46,113 +17.1%

「短い説明文がありうる」は仮説ではありませんでした。get_session_findings が既に短文を 出荷しており、削減量は見積りではなく実測できたからです。

選択は アーム D。 全文の中で荷重を担っている節は「保存データを絞らない (絞るなら agent_id / project_id / channel)」— §2 の線を、読者が最も踏み越えやすい地点で述べたもの です。「これは自分が見られる記憶を絞るのか?」が実際に問われるのは recallrecall_with_context なので、この 2 本は全文を保ちます。それ以外は、この節を残して周辺の 敷衍を落とした圧縮版を持ちます。

訂正 — 最初の見積りは誤りでした。 アーム D は当初 get_session_findings が持つ 206 文字のテキストで値付けされましたが、そのテキストにはアーム D が残すために存在する当の 節が入っていません。つまりアーム D をアーム C の説明文で値付けしていました。実際の圧縮 テキストは 1 ツールあたり 289 文字で、全文は 509 文字です。したがってアーム D の実コスト は最初の見積りより高くなります。順位は変わりません (どのアームも A よりはるかに下です) が、 提示された数値は別の文字列を測っていました。実際の文言を書いた後で測り直さない見積りは、 表の体裁をまとった当て推量です。

段階 2 の実コスト

配線が入った後に、同じ方法で実測した値。30 ツール中 22 本が key を持ちます。

シリアライズ後の文字数
session_key を一切持たないツール一覧 39,381
出荷時のツール一覧 46,113
パラメータ分 +6,732 (+17.1%)

1 ツールあたり: 全文を保つ 2 本が 509、圧縮版が 289、get_session_findings が自前で持つ ものが 206。

この合計のうち 411 文字はパラメータではありません。 key なしの基準そのものが 38,970 から 39,381 へ動いています。pause 系 3 ツールの説明文自体を書き直す必要があったためです — 3 本 とも「pause はプロセス全体で、接続中の全セッションを黙らせる」と書いており、それはまさに段階 2 が偽にすることです。放置する方が安く、そしてツール一覧が自分の説明するツールについて嘘をつく ことになります。パラメータの数値に畳み込まず別建てにしたのは、この 2 つが別のコストであり、 アームが選んでいたのは一方だけだからです。

7. 寿命

分割対象の値と、それを支える帰属マップは、いずれも永続行ではなく上限付きのプロセス内 マップです。

  • pause は既に TTL を持ち遅延クリアされます。key 単位でも同じ TTL が適用され、 期限切れのエントリは消えます。
  • advisory の抑制エントリは追い出し上限で抑えます。key 空間はクライアント供給であり、 key をローテーションするクライアントがマップを無制限に育ててはならないためです。 追い出しが忘れるのは「そのセッションには既に伝えた」ことだけなので、最悪ケースは 通知の重複 — 安全側です。
  • pause のエントリにも上限がありますが、そちらの追い出しは安全側ではありません。 pause を忘れることは、そのセッションの write を再開させるからです。したがって 2 つの マップは方針を共有しません。pause の上限は、到達するとしたら「クライアントが呼び出し ごとに key をローテーションしている」場合に限られる高さに置き、追い出す対象は deadline が最も近いもの — どのみち最初に期限切れになる pause が、少しだけ早く切れるだけです。
  • キューの「タスク → key」帰属マップはプロセス内にあり、記述対象のタスク行と一緒に削除 されるので、キューより長生きすることはありません。
  • プロセス再起動で 3 つとも消えます。これは正しい挙動です。再起動をまたいで生き残る セッションは存在しません。再起動を生き延びたキュー行は transport バケットに fallback します。

ディスク上に GC すべきものは無く、key が DB に届くこともありません。

8. 縮退の契約

key を申告しない呼び出し元にも、推測ではなく事実を伝えます。ただし何を伝えるかは、その 応答が既に何を言っているかで決まります — key なしの応答は形を変えてはならないからです (新しいキーを 1 つ足すだけで §3 が約束する保存が壊れます):

  • get_session_findings は従来どおり、共有トランスポート上で key なしの呼び出し元に identity_shared: true を載せます (SuperAuditor 標準の要求。このフィールドは本設計より 前から存在します)。
  • pause の 3 ツールは、key なしの呼び出し元に scope: "process" と答えます。影響範囲を 正確に述べており、従来とバイト単位で同一の応答です。申告した呼び出し元には代わりに scope: "session" を返します。段階 1 の所有者フィールド (pause_owner_known / paused_by_self) は廃止しました。他セッションの pause が自分に届かなくなった以上、 それが答えていた問い自体が立たなくなったためです。
  • recall の応答は advisory_scope で regime を伝えますが、それは advisory の内側、つまり recall が劣化している時にだけ現れます。健全な key なし recall は何も増えません — そこが 要点です。

stdio では共有の同一性を主張するものは何もありません。プロセスがセッションそのものであり、 それ以外を言うのは虚偽になります。

9. バージョンの置き場所

段階 12.5.7 beta 系列に載りました。ラインは 2.5.7b1 で alpha から beta へ 昇段し、その b1 が運ぶのは findings の pull ツールです。段階 1 は同系列の次のリリースで あって、b1 そのものではありません。その論拠は、加算的・挙動保存・スキーマ変更なしで あり、それはリリースライフサイクル標準が Current tier のライン内で許容するものだ、という ものでした (§2.6 — 次のラインまで待つのはロールバックできない変更)。

段階 2 はその論拠を借りられませんし、借りたふりをしてはいけません。 既定の挙動を 変え (接続中の全クライアントを黙らせていた pause が、1 セッションだけを黙らせるように なる)、段階 1 が出荷した応答フィールドを 2 つ削るからです。標準 (§2.1) の下では、これは まさに pre-release ladder の発火条件です: ロールバック安全でない・既定の挙動の変更・ ツール契約の破壊。したがって、別のコードが soak した graduation に相乗りせず、自分の ladder を開きます。 本件が用意できた時点で 2.5.7 系列は既に graduation を終えていたため、段階 2 は新しい系列 を 2.5.8a1 から始めます。beta ではなく alpha なのは、最初の段の目的が「per-session の pause を実トラフィックの前に置くこと」であって、まだ ready と呼ぶ段ではないからです。

ここから 2 つの帰結が出ます。どちらも省略できません:

  • その系列の到達点は 2.5.8 final であり、別の番号へ飛びません。ladder が完了した 時点で soak していた中身が final になる中身です — 誰も動かしていない版を名乗る graduation は、このラインが既に一度犯した失敗です。
  • 「pause の前に並列セッションの不在を確認せよ」と別の場所に書かれた運用指示は、古い影響 範囲を前提にしています。それは段階 2 が取り除く挙動の記述であり、本件の出荷と同時に 陳腐化します。

10. テスト

  • 解決: 申告あり / 不在 / 空 / 空白のみ の 4 ケースで、返る key と declared フラグの 両方を検査する。
  • 挙動保存: 既存の key なし呼び出し列が、両トランスポートで前後同一の応答を返す。 key なしの呼び出し元が従来持たなかったフィールドが増えていないことも含める。
  • advisory: 1 回の障害中に異なる 2 つの key がそれぞれフル runbook を受け取る。同一 key の 2 回目は短縮形になる。key なし remote の 2 者は今日の transport スコープの挙動を保つ。
  • per-session の pause: ある key が張った pause はその key の write を skip し、別の key と key なしバケットには影響しない。key なしの pause では逆になる。scope は pause / resume / status の 3 つすべてで、申告ありなら session、key なしなら process を返す。
  • TTL: pause は key 単位で期限切れになる。実時間を sleep するのではなく時計を注入して 検査する。resumewas_active は自分の key についてのみ真を返す。
  • 正直さ: key なし remote の応答は identity_shared: true を持ち、stdio の応答はそもそも 持たない。
  • コピーされた不変条件: TTL の引数規則が、コピー元の vendored モジュールと一致することを bool・0・負値・上限・非整数にわたって検査する。このテストが、再 vendor 時にコピーを 正直に保つ唯一の仕組みである。
  • 構造: vendored の pause スイッチ呼び出しが、置き換えたモジュール以外のパッケージ内に 1 つも残っていない。検索する文字列は連結で組み立て、テスト自身が自分のパターンに 一致しないようにする。
  • キューの帰属: ある key で投入されたタスクは、その key が pause した時に捨てられ、別の key が pause した時には捨てられない。帰属の無い行は transport バケットで判定される。
  • 変異証明: strip() の除去・空文字ガードの除去・advisory の追い出し上限の除去・pause の 追い出し上限の除去・pause 参照の per-session 分岐の除去・skip 応答の TTL 差し替えの除去・ キューの帰属参照の除去が、それぞれ欠陥を名指しするテストで落ちる。