コンテンツにスキップ

記録されたアクセス元 (origin)

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

ステータス: 2.5.x プレリリースラインへの提案。ツール契約に対しては加算的です — 引数が 増えるツールは無く、既存の応答の形も変わりません。ただしデータベースに列を 1 本足すため、 リリース標準の定義ではロールバックフリーではなく、プレリリースの梯子を通ります (RELEASE_LIFECYCLE_STANDARD.md §2.1)。

1. 問題: agent_id は出自を運ばない

保存された行は「これは誰の記憶か」に agent_id で答え、「誰がその内容を produce したか」に source で答えます。どちらも「どの呼び出し元がここに置いたか」には答えず、そしてこの 3 つは 日常的に一致しません。出荷済みコードのうち 4 つの経路が、出自を復元できない行を作ります。

  • 意図的に共有された agent_id 複数のオペレーターを 1 つのエージェント名前空間に 向けるのは意図的な選択でありえます — それが互いの履歴を読めるようにするからです。そう した瞬間、書き手を区別する唯一の手がかりは source、つまり書き手が自分自身について 申告した値だけになります。
  • unscoped な書き込み。 do_store は空の agent_id を受理し、agent_id = '' の行を 書きます。これは事故ではなく意図的な injected-trust の継ぎ目ですが (bug-137)、残る行は 誰の名も持ちません。
  • import_memories 取り込まれた行は取り込み側の agent_id と、書き出し側の source をそのまま受け取ります。誰がいつ取り込んだかは、どこにも記録されません。
  • 捨てられる検証済み subject。 _oauth_principal は検証した iss / sub クレームを principal に載せます。クライアントの行が per-subject 分離を opt-in していない限り (OAUTH_DESIGN.md §12)、下流の誰も subject を消費せず、リクエストの終わりと共に消えます。

情報は書き込みの瞬間には存在しています — acl.current_principal() はどのハンドラ内でも Principal(client_id, issuer, subject) を返します — そしてそのまま捨てられます。本ドキュメントは それを保持することについてのものです。

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

これは測定であって、主張ではありません。 値はサーバーが解決したものからサーバーが書きます。 引数として受け取るツールはありません。この 1 つの規則こそが、列を足す価値そのものです — 呼び出し元が書ける field は既に source であり、2 本目を足しても 1 本目以上の証拠にはなりません。

これは分離軸ではありません。 agent_id / project_id / channel はクエリがどの行を読むかを 選びます。origin は何も選びません。これで絞られる recall は無く、これによって到達可能・不能に なる行もありません。書いた identity で記憶を絞ることは、記憶がそれを越えるために存在する境界で 記憶を読めなくすることです — session.py が申告型セッションキーについて述べているのと同じ規則で、 理由も同じです。

これは認可の入力ではありません。 アクセス判定は ACL 層に属し、ハンドラに到達する前に走ります。 事後に読む列は何もゲートできませんし、判定に配線すればポリシーが 2 箇所に散ります。

これは個人の identity ではありません。 行に着地するのはサーバーが発行した不透明な alias で あって、生の sub クレームではありません。§5 を参照。

3. なぜ source の拡張ではないのか

source はスキーマ強制の無い JSON 列なので、サブキーを足すだけならマイグレーションは一切 不要です。この経路は実測した上で却下しました。既存 field の 2 つの性質がこれを打ち消し、 しかもその 2 つは今いる場所で荷重を負っています。

source は呼び出し元が書く field で、書き込み経路は未知のキーを保存します。 normalize_source は legacy な shape を canonical な契約に畳むために存在しますが、それは 何も捏造せずに 行われます — discriminator を捏造すれば attribution を偽り、 anonymous-source 検出器を無効化するからです。したがってキーのホワイトリストはどこにも ありません。到達する経路は未知 shape 向けの寛容な分岐ではなく、現在のすべての producer が 通る canonical fast path です。

# (1) Already canonical — the fast path used by every 2.5.x producer.
raw_type = source.get("type")
if isinstance(raw_type, str) and raw_type in CANONICAL_SOURCE_TYPES:
    return source, False

{"type": "Agent", "id": "...", "origin": {...}} を送った呼び出し元は、その dict をそのまま 返され、書き込みの継ぎ目でそのまま直列化されます。type が妥当なので check_health(invalid_source_type) にも掛からず、偽装された origin はどこにも異常として 現れません。これを塞ぐには、呼び出し元の値をそのまま残すために書かれた唯一の関数の中に、 verbatim 保存の契約より手前で剥がす処理を足すことになります。

source は recall が返します。 スコア付きの各行がこれをモデルへ持ち帰ります。ここに origin を置けば、クライアント識別子・subject alias・セッションキーが recall のたびに コンテキストウィンドウへ入ります — hot path でトークンを払い、それを使い道のないモデルが 読むことになります。避けるには読み出し経路でサブキーを剥がすことになります。

この 2 つの修理は、observed/declared の分離と admin 限定の読み出し面を、より悪い場所に 書いたものです。列を分ければ両方が構造的に得られ、その代償であるマイグレーションは、この スキーマの梯子が何度も通してきた唯一の形です。

4. 継ぎ目

storearchive_episode の書き込み経路から呼ばれる 1 つのヘルパーが値を解決します。

  • clientPrincipal.client_id。stdio とローカル principal ではローカルクライアントの 定数、プロバイダ発行トークンでは ACL 層が既に使っている issuer 名前空間付きの識別子 (oauth:<issuer>:<client_id>) なので、行と grant 行が同じものを読みます。
  • subject alias — principal が検証済み subject を運び、かつ それに alias が発行済みの 場合のみ入ります。生の sub は決して入りません。
  • transport — 呼び出しが stdio と共有 HTTP トランスポートのどちらで届いたか。これは 「1 プロセス 1 クライアント」と「1 プロセスが全員」の違いであり、client field がどれだけの 価値を持つかを後の読み手に伝えるものです。
  • 申告されたセッションキー — 呼び出し元が送っていればその値。

解決の失敗はエラーではありません。principal が無い、subject に alias が無い、transport が 決められない — それぞれ自分のキーを省きます。空のオブジェクトは「解決可能な出自を運ばなかった 呼び出し」の正直な記録であり、同時に既存のすべての行が持つ値でもあるので、両者は意図的に 区別できません — §7 を参照。

5. 観測と申告を同じ袋に入れない

{
  "observed": {
    "client": "oauth:https://auth.example/:client_abc",
    "subject_alias": "u-1a2b3c",
    "transport": "http"
  },
  "declared": { "session_key": "..." },
  "at": "2026-01-01T00:00:00+00:00"
}

入れ子こそが要点です。observed はサーバーが解決したものを持ちます。OAuth 経路ではその フィールドはハンドラが走る前に検証された署名済みクレームに由来し、それが読み手に証拠として 扱う資格を与えます。declared は呼び出し元が自分自身について述べたものを持ちます。申告された セッションキーは比較されるだけで検証されません — 誰でも任意の文字列を、他のセッションのものを 含めて送れます — ので、検証済み subject と同じオブジェクトではなく、信頼度が読み取れる場所に 記録します。

平坦な blob の方が小さくなりますが、この列が存在する理由そのものを失います。1 年後に問題のある 行を監査する人は field を 1 つ読むだけで、行の他のどこにも、どちらの半分が測定値かは書かれて いません。

subject ではなく alias を。 subject の記憶が置かれる名前は既にサーバー発行の不透明な alias であり、(issuer, subject) → alias の対応は運用者が編集できる台帳にあります。これは、プロバイダ 移行や pairwise 識別子への切り替えが、孤立したデータではなく 1 ファイルの修理で済むようにする ためです (OAUTH_DESIGN.md §12)。生の sub を記憶行に書けば、その孤立化を行ごとに、しかも 台帳の編集が届かない列で再現することになります — 加えて、export され recall される面に個人の プロバイダ識別子を置くことになります。

6. 読み出し面

フェーズ 1 では origin を検査系ツールにのみ出します — 行単位の admin 読み出しと health 面です。 recall / recall_with_context には 足しません

この非対称は意図的です。recall 応答にフィールドを後から足すのは加算的で、外すのは契約破壊です。 コストがゼロの側から始めれば選択肢は開いたままになりますし、この列が答える運用者向けの問い (「どの呼び出し元がこの行を書いたか」) は、モデルが recall の途中で答える問いではありません。

同じ理由で、recall のフィルタも提案しません。source_id は既に申告された attribution で 絞りますし、origin 型のフィルタは独自の判断を要する別の機能です。加えてそれは per-subject 境界のすぐ隣に座ることになります — あの境界は構造的に引き算であり、そこを通る 2 本目の 加算的な経路を得てはなりません。

7. これで分からないこと

後から発見するのではなく、ここに書いておきます。

  • stdio ではほぼ定数です。 ローカル principal は固定のクライアント id を持ち subject を 持たないので、単一ユーザーのローカル導入ではすべての行に同じ 2 フィールドが記録されます。 この列が働くのは、1 プロセスが多数の呼び出し元を捌く共有 HTTP トランスポートです。stdio から 出ない配備は、ここから情報を期待すべきではありません。
  • 遡及しません。 マイグレーション前に書かれたすべての行は {} を持ち、マイグレーション後で あっても解決可能な出自を持たない呼び出しが書いた行は同じく {} です。この列はどちらかを 言えません — 「これが出荷される前に書かれた」と「識別できない呼び出し元が書いた」は設計上 同じ値です。区別を捏造することは、誰も測っていない行についての主張を書くことだからです。
  • import は取り込んだ人を記録します。 取り込まれた行の origin は、元の内容を書いた人ではなく 取り込みを実行した人を指します — 前者は知りようがなく、書き出し側の値をここで測定されたかの ように持ち越してはなりません。そうでなければこの列は偽造の面になり、それは §3 が source を 却下した失敗様式そのものです。

8. スキーマと互換性

memoriesepisodes にそれぞれ origin TEXT NOT NULL DEFAULT '{}' を足します。列を足す これまでのマイグレーションが使ってきたのと同じ ALTER TABLE ... ADD COLUMN の一歩であり、次の スキーマバージョンとしてスタンプします。

古いビルドはこの列を許容します。これは仮定ではなく確認しました。

  • すべての insert が列を明示的に指定しているので、追加された列が古い writer にとって位置依存に なることはありません
  • パッケージ内に SELECT * は存在しないので、予期しないフィールドを受け取る reader はいません
  • 走っているビルドより新しいスタンプの付いたデータベースは、警告を出して続行します (bug-138)。 よってダウングレードは起動拒否ではなく縮退になります

したがってロールバックしても列はそのまま残り、読まれません。これはロールバック 耐性 であって ロールバックフリーとは違います。依然としてスキーママイグレーションであり、リリース標準は、 どれほど穏やかに見えてもそれをプレリリースの梯子へ送ります。

9. テスト

上の主張は、それを支える検査の分だけしか価値がありません。

  1. source の中に origin キーを送った呼び出し元が、保存される origin 列に影響しないこと — §3 が実測した偽造経路を、直接アサートする。
  2. 解決可能な principal を持つ呼び出しは client を記録し、持たない呼び出しは {} を記録する。
  3. 検証済み subject は alias を記録し、生のクレーム値は決して記録しない — alias の一致で アサートし、加えて直列化された行に生の subject 文字列が存在しないことを別途アサートする。
  4. recall / recall_with_context の応答に origin キーが含まれないこと。これにより §6 が 慣習ではなくゲートになる。
  5. import は、取り込まれたペイロード内の値ではなく、取り込みを行った呼び出し元の origin を書く。
  6. マイグレーション前に作られたデータベースが開き、マイグレーションされ、origin{} の 行を返す。