---
read_when:
    - exec または Plugin の承認ライフサイクル、ストレージ、プロトコル、認可の変更
    - チャネルへの承認リンクまたはネイティブ承認コントロールの追加
    - 子セッションの承認を親ビューまたはオーケストレータービューに投影する
summary: Control UI、ネイティブアプリ、チャンネル、親セッション全体で永続的かつディープリンク可能な承認を実現する設計
title: 複数サーフェスでのオペレーター承認
x-i18n:
    generated_at: "2026-07-12T14:52:18Z"
    model: gpt-5.6
    postprocess_version: locale-links-v1
    prompt_version: 15
    provider: openai
    source_hash: 3f3dfc5d503d46bfc7a5eb94960baf2a81216ac973ef1bb1e6a0ef63f0bec6d5
    source_path: refactor/operator-approvals.md
    workflow: 16
---

# 複数サーフェスでのオペレーター承認

この設計は [#103505](https://github.com/openclaw/openclaw/issues/103505) を追跡するものです。プロセスローカルな承認権限を、Gateway が所有し SQLite を基盤とする単一のライフサイクルに置き換えます。Gateway が所有するすべての exec または plugin/ツール承認には、1 つの安定した ID、認証済みの Control UI ルート、アトミックな先着回答優先の解決、およびその発生元と祖先セッションストリームへのオペレーター専用プロジェクションが与えられます。

インラインアクションとディープリンクは共存します。承認モードの切り替えはありません。

## 目標

- exec および plugin/ツールゲートに対する単一の永続的な承認オブジェクト。
- 安定した `${controlUiBasePath}/approve/{approvalId}` ルート。
- 認可された任意の Control UI、ネイティブアプリ、またはチャネルサーフェスからの解決。
- 同時操作される複数サーフェス間でのアトミックな先着回答優先動作。
- 同一内容の再試行は冪等であり、競合する遅延回答は確定済みの回答を上書きできない。
- タイムアウト、不正な信頼済み判定、ルート欠落、キャンセル、再起動ではフェイルクローズする。
- 要求イベントと終端イベントが、発生元セッションおよび関連するすべての親/オーケストレーター所有者に届く。
- チャネルは型付きの承認アクションとナビゲーションアクションを受け取り、トランスポートのコールバックデータはチャネル内に限定される。
- 既存の exec/plugin Gateway メソッドとの互換性を維持しつつ、その実装を単一のサービスへ集約する。

## 非目標

- Gateway 再起動をまたいで、ブロックされたツール実行自体を永続化または再開すること。
- 承認 ID や URL をベアラー認証情報にすること。
- モデルに見えるトランスクリプトへ承認プロンプトを追加したり、親エージェントを起動したりすること。
- 承認ポリシー、プロダクトコマンド、またはレビュアー認可をチャネル plugin に移すこと。
- チャネル、デバイス、または祖先ごとに承認状態を複製すること。
- 終端結果を曖昧なくするために必要な場合を除き、exec の許可リスト、plugin ポリシーの合成、または `allow-always` の永続化を再設計すること。
- 最初の増分で、Gateway を持たない組み込み TUI にリモートアクセスできるようにすること。これはローカル専用のままであり、レビュアーが存在しない場合はフェイルクローズしなければならない。

## ロールアウト前のベースラインとエビデンスマップ

この表は、#103505 が作成された時点の実装状態を記録しています。以下のロールアウトセクションでは、そのベースライン上に構築される永続レジストリ、型付きアクション、ディープリンクページ、ネイティブクライアントの各増分を追跡します。

| サーフェス           | ベースラインのエントリポイントと所有者                                                                                                                                  | ベースラインの動作と不足点                                                                                                                                                                    |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| エージェント exec        | `src/agents/bash-tools.exec-approval-request.ts`, `src/agents/bash-tools.exec-host-shared.ts`                                                                   | 2 フェーズの `exec.approval.*` 登録によって早期の `/approve` 競合は防止されますが、タイムアウトが `askFallback` を介して許可になる可能性は残っています。                                                        |
| Plugin ツールゲート  | `src/agents/agent-tools.before-tool-call.ts`                                                                                                                    | `plugin.approval.*` を要求します。`timeoutBehavior: "allow"` により、タイムアウトしたゲートが承認される可能性があります。組み込みモードは `src/infra/embedded-plugin-approval-broker.ts` に別個のプロセスローカル権限を持ちます。 |
| Plugin Node ゲート  | `src/gateway/node-invoke-plugin-policy.ts`                                                                                                                      | plugin マネージャーを介して直接作成およびブロードキャストし、サーバーメソッドのライフサイクルの一部を重複実装しています。                                                                                 |
| Gateway 権限 | `src/gateway/server-aux-handlers.ts`, `src/gateway/exec-approval-manager.ts`, `src/gateway/server-methods/approval-shared.ts`                                   | exec と plugin の個別マネージャーがプロセスローカルなマップを使用します。終端エントリは 15 秒間保持されます。先着回答優先が成立するのは単一プロセス内だけです。                                          |
| Gateway プロトコル  | `packages/gateway-protocol/src/schema/exec-approvals.ts`, `packages/gateway-protocol/src/schema/plugin-approvals.ts`, `src/gateway/methods/core-descriptors.ts` | exec には保留中のみを対象とする `get` があります。plugin には `get` がありません。ディープリンク用の種別非依存の終端検索は存在しません。                                                                                   |
| 配信          | `src/infra/exec-approval-channel-runtime.ts`, `src/infra/approval-native-runtime.ts`, `src/infra/approval-handler-runtime.ts`                                   | 発生元ルーティング、承認者への DM、保留中項目の再生、ネイティブハンドラー、プロセス内での終端クリーンアップをサポートします。別のフォローアップで永続的な終端状態の整合処理を追加します。                          |
| ポータブルアクション  | `src/interactive/payload.ts`, `src/plugin-sdk/interactive-runtime.ts`, `src/plugin-sdk/approval-reply-runtime.ts`                                               | 承認ボタンは `/approve ...` を含むコマンドアクションです。URL と Web App のターゲットは型なしのボタンフィールドです。                                                                           |
| Telegram          | `extensions/telegram/src/approval-handler.runtime.ts`, `extensions/telegram/src/button-types.ts`                                                                | レンダラーは、非公開のコールバックデータを生成する前に、コマンドテキストを解析して承認のセマンティクスを認識します。                                                                                     |
| Control UI        | `ui/src/app/exec-approval.ts`, `ui/src/app/overlays.ts`, `ui/src/components/exec-approval.ts`                                                                   | 承認 UI はグローバルモーダルです。`ui/src/app-route-paths.ts` と `ui/src/app-routes.ts` は完全一致ルートを使用し、不明なパスを Chat に書き換えます。                                                    |
| セッション所有権 | `src/agents/subagent-registry.types.ts`, `src/agents/subagent-registry-read.ts`, `src/config/sessions/types.ts`                                                 | コントローラー、要求元、明示的な親、およびレガシーな起動元の所有権は存在しますが、承認イベントはそれらのセッションストリームにプロジェクションされません。                                                    |
| 共有状態      | `src/state/openclaw-state-schema.sql`, `src/state/openclaw-state-db.ts`                                                                                         | 既存の即時トランザクションと Kysely の条件付き更新により、`state/openclaw.sqlite` で永続的な比較交換が可能です。                                                                   |

現在の代表的なテストには、`src/gateway/exec-approval-manager.test.ts`、`src/gateway/server-methods/approval-shared.test.ts`、`src/agents/bash-tools.exec-gateway-approval.e2e.test.ts`、`extensions/telegram/src/approval-handler.runtime.test.ts`、`ui/src/e2e/approval-flow.e2e.test.ts` があります。

plugin SDK は引き続き、唯一のチャネル/plugin 境界です。承認ランタイムとプレゼンテーションの変更は、既存の `src/plugin-sdk/approval-*.ts` および `src/plugin-sdk/interactive-runtime.ts` サブパスを通じてエクスポートする必要があります。plugin の本番コードは Gateway の内部実装をインポートしてはなりません。

## 先行事例

Omnigent は有用な UX と障害時のセマンティクスを提供しています。

- [`approval.py`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/omnigent/runtime/policies/approval.py) は ASK を待機状態にし、ポリシーごとのタイムアウトを適用し、完全一致する承認だけを承認として扱います。
- [`sessions.py`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/omnigent/server/routes/sessions.py) には、サーバー側のネイティブハーネスゲートと、祖先への要求/解決プロジェクションが含まれます。
- [`ApprovePage.tsx`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/web/src/pages/ApprovePage.tsx) は、独立したモバイル承認ページを提供します。

そのストレージに関する主張を無批判にコピーしないでください。現在アクティブな保留状態は [`_elicitation_registry.py`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/omnigent/server/_elicitation_registry.py) のプロセスローカル状態であり、未使用の保留テーブルは [`e3b1f2a4c9d7_drop_pending_tool_calls_table.py`](https://github.com/omnigent-ai/omnigent/blob/46e3cd9754c3b8567f7b09f4d19b6249dabe0e80/omnigent/db/migrations/versions/e3b1f2a4c9d7_drop_pending_tool_calls_table.py) によって削除されています。OpenClaw は意図的にさらに先へ進みます。SQLite を権威ある状態とし、すべての終端遷移をデータベースの比較交換として実行します。

## アーキテクチャと所有権

Gateway がライフサイクルを所有します。

1. エージェント、plugin フック、または Node ポリシーが、種別固有の要求とプロセスローカルな実行バインディングを提供します。
2. Gateway がそれを検証し、サニタイズ済みのレビュアー向けプロジェクションを構築します。
3. 承認サービスが発生元/所有者のオーディエンスを算出し、正規行を挿入してから、プロセス内の待機処理を登録します。
4. 永続的な挿入後、Gateway は既存の承認イベント、セッションプロジェクション、チャネル通知、ネイティブプッシュを発行します。
5. すべてのサーフェスが同じサービスを通じて解決します。
6. サービスが 1 つの終端遷移をコミットし、ランタイムの待機処理を起動して、終端プロジェクションを発行します。
7. イベント配信に失敗しても、コミット済みの決定はロールバックされません。クライアントは `approval.get` またはリストの再生を通じて復旧します。

所有権の境界:

- `src/gateway/`: 承認サービス、認可、RPC アダプター、URL 構築、待機処理のライフサイクル、イベント発行。
- `src/state/`: 共有スキーマと生成された Kysely 型。
- `src/infra/`: サニタイズ済みの承認ビューモデルとポータブルなプレゼンテーションの構築。
- `src/agents/`: 返された判定の要求、待機、適用。永続化は行わない。
- `src/channels/` および `extensions/*`: 型付きアクションのレンダリング、チャネルユーザーの認可、非公開コールバックのエンコード、配信済みコントロールの更新。
- `src/plugin-sdk/`: 公開の承認契約とプレゼンテーション契約のみ。
- `ui/`: 独立ページと既存のキュー/モーダルクライアント。

プロセス内の待機処理は通知メカニズムであり、権威ではありません。登録処理は要求を発行する前に同期的に行を挿入して待機処理を設定するため、解決処理がそれらの手順の間に割り込むことはできません。それ以降のすべての解決処理は、待機処理を完了させる前に SQLite を通じてコミットします。

## 永続レコード

共有状態データベースに 1 つの `operator_approvals` テーブルを追加します。

| 列                                                 | 目的                                                                                                                                                                                      |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `approval_id`                                      | グローバルに一意な正規 ID。プロトコル互換性のため既存の exec ID と `plugin:` ID は維持しますが、プレフィックスから種別を推測してはなりません。                                            |
| `resolution_ref`                                   | 正規 ID を格納できないトランスポートコールバック用の、一意な完全 SHA-256 base64url ロケーター。これは認可でも公開 URL ID でもありません。                                                  |
| `kind`                                             | 閉じた `exec \| plugin` 判別子。                                                                                                                                                           |
| `status`                                           | 閉じた `pending \| allowed \| denied \| expired \| cancelled` 状態。                                                                                                                       |
| `presentation_json`                                | 検証済みで種別タグ付きのレビュアー向けプロジェクション。未加工のランタイムリクエスト、コマンドバインディング、コールバックペイロードはプロセスローカルのままです。                          |
| `source_agent_id`, `source_session_key`            | ソース ID とセッションプロジェクションのアンカー。セッションキーは永続的ですが、ローテーションされるセッション UUID は永続的ではありません。                                             |
| `audience_session_keys_json`                       | 境界付き幅優先の所有権探索によって生成された、順序付きで重複を除去した JSON 配列。要求イベントと終端イベントは同じスナップショットを使用します。                                             |
| `requested_by_device_id`, `requested_by_client_id` | 永続的な要求元／監査メタデータ。接続 ID はメモリ内に保持され、サーフェスをまたぐプリンシパルではありません。                                                                                |
| `reviewer_device_ids_json`                         | 信頼された承認ランタイムのみが指定する、明示的な対象レビュアーデバイス（任意）。                                                                                                           |
| `runtime_epoch`                                    | 保留中の実行を所有するプロセスエポック。再起動後に孤立した行をキャンセルするために使用します。                                                                                             |
| `created_at_ms`, `expires_at_ms`, `updated_at_ms`  | 権威あるタイミング情報。                                                                                                                                                                   |
| `decision`                                         | 存在する場合の明示的なユーザー決定。                                                                                                                                                       |
| `terminal_reason`                                  | `user`、`timeout`、`malformed-verdict`、`no-route`、`run-aborted`、`gateway-restart` などの閉じた理由。                                                                                     |
| `resolved_at_ms`, `resolver_kind`, `resolver_id`   | サーバー側に保持される勝者と監査 ID。レビュアー向けプロジェクションでは未加工の解決者識別子を省略します。                                                                                   |
| `consumed_at_ms`, `consumed_by`                    | `allow-once` 用の独立したリプレイ防止機構。消費時に記録済みの決定を消去してはなりません。                                                                                                  |

必須インデックス：

- 一意な `(resolution_ref)`。挿入時には列をまたぐ `approval_id`／`resolution_ref` の曖昧性も拒否
- `(status, expires_at_ms)`
- `(source_session_key, created_at_ms DESC)`
- 保持期間のプルーニング用の `(resolved_at_ms)`

オーディエンス配列は小さく、上限が設定されています。セッションでフィルタリングしたリプレイでは、まず Kysely を介して可視な保留行を選択し、次にアプリケーションコードで上限付きオーディエンス配列をデコードしてフィルタリングします。文字列一致や未加工 SQL の JSON クエリは使用しません。

終端行は、`src/audit/audit-event-store.ts` のメタデータ監査保持期間に合わせて 30 日間保持します。プルーニングは固定の保守ポリシーであり、新しい設定サーフェスではありません。データベースは非公開のローカルコントロールプレーン状態ですが、レビュアー API は保存された完全なリクエストやランタイムバインディングを決して公開してはなりません。

## 状態機械と比較交換

有効な遷移は次のものだけです：

- `pending -> allowed`：明示的な `allow-once` または `allow-always`。
- `pending -> denied`：明示的な拒否、信頼された不正形式の終端判定、または配信経路なし。
- `pending -> expired`：権威ある期限への到達。
- `pending -> cancelled`：実行の中止、正常終了、または再起動時の孤立状態の復旧。

許可以外のすべての終端状態では、実効的な判定は拒否です。

解決には、1 つの即時 SQLite トランザクションと、次と同等の Kysely 条件付き更新を使用します：

```sql
UPDATE operator_approvals
SET status = ?, decision = ?, terminal_reason = ?, resolved_at_ms = ?
WHERE approval_id = ?
  AND status = 'pending'
  AND expires_at_ms > ?;
```

更新の影響を受ける行がない場合、同じトランザクションでレコードを読み取ります：

- 存在しない、または認可されていない場合：見つからないものとして返し、存在を明らかにしない。
- まだ保留中だが期限に達している場合：比較交換で `expired` にしてから、その終端行を返す。
- 記録済みの決定と同じ場合：記録済みの勝者とともに冪等な成功を返す。
- 異なる決定の場合：統合 API は記録済みの勝者とともに `applied: false` を返す。レガシーアダプターは、出荷済み契約で必要な場合に `APPROVAL_ALREADY_RESOLVED` を維持する。
- いずれかの終端状態の場合：決して変更しない。

`now == expires_at_ms` は期限切れです。Gateway の時刻が権威を持ちます。

`allow-once` の実行では、既存の厳密なコマンド／システム実行コンテキストにバインドされた `consumed_at_ms IS NULL` に対して、2 回目の CAS を使用します。承認行は消費後も監査記録として残ります。

認証できない、または承認を特定できない不正形式の HTTP/RPC 入力は、変更せずに拒否され、承認につながることは決してありません。既知の承認について、信頼されたハーネス／待機処理から不正形式の終端判定を受信した場合は、`denied` に遷移します。

## Gateway API

種別に依存しないレビュアーメソッドを追加します：

| メソッド                                  | 契約                                                                                                                                                                                                                                             |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `approval.get { id }`                     | 可視な保留中または保持済みの終端プロジェクションを返します。                                                                                                                                                                                     |
| `approval.resolve { id, kind, decision }` | 正規 ID または固定サイズのトランスポート参照を受け付け、認可、種別と許可された決定の検証、期限の整合、終端 CAS を実行します。レスポンスには常に正規 ID が含まれます。                                                                               |

CAS が成功した後、コミット済みのプロジェクションを直ちに返します。レガシーイベント、チャネルフォワーダー、プッシュ終端処理はベストエフォートの後続処理です。低速または失敗したサーフェスが、勝利したレスポンスを遅延させたりロールバックしたりしてはなりません。

種別固有のリクエスト検証は `exec.approval.request` と `plugin.approval.request` に残します。既存の `exec.approval.get/list/waitDecision/resolve` と `plugin.approval.list/waitDecision/resolve` は出荷済み Gateway API であるため、正規サービスへのプロトコル境界アダプターになります。内部呼び出し元は同じ変更内でサービスへ移行します。

レビュアー向けプロジェクションはタグ付きユニオンです：

```ts
type OperatorApproval = {
  id: string;
  status: OperatorApprovalStatus;
  presentation:
    | { kind: "exec"; commandText: string /* 安全な exec プレビュー */ }
    | { kind: "plugin"; title: string; description: string /* 安全な Plugin プレビュー */ };
  // 共通のライフサイクルフィールド
};
```

安定したパスは永続化せず、導出します。`approval.get` は `urlPath` を返します。承認済みの公開オリジンを把握しているサーフェスは、絶対 `url` も受け取れます。レビュアーのスナップショットでは、ソースおよびオーディエンスのセッションキーを省略します。Gateway は、独立した `session.approval` プロジェクション用として、それらのルーティングキーをサーバー側に保持します。

## イベントとポータブルアクション

PR 1 では、出荷済みのイベント名、ペイロード、既存のレコードレベル受信者フィルターを維持します：

- `exec.approval.requested`
- `exec.approval.resolved`
- `plugin.approval.requested`
- `plugin.approval.resolved`

これらのレガシーイベントには完全なランタイムリクエストが含まれる可能性があるため、承認スコープのすべてのクライアントへファンアウトしてはなりません。PR 5 では、レガシーイベントの配信範囲を広げる代わりに、サニタイズ済みのライフサイクルプロジェクションを通じてタグ付きライフサイクルフィールド（`status`、`sourceSessionKey`、`urlPath`、終端メタデータ、プレゼンテーションレベルの `kind`）を追加します。

承認スコープの `session.approval` プロジェクションイベントを追加します。永続化されたオーディエンスキーを使用して正規イベントを 1 回発行し、完全一致セッションの購読者は一致する各キーについて同じイベントを受信します：

- `sessionKey`：プロジェクションを受信するストリーム。
- `sourceSessionKey`：ゲートを発生させた子／ソース。
- `phase`：`pending \| terminal`。承認ステータスに対する判別子。
- 1 つの安全な `OperatorApproval` プロジェクション。

クライアントは `sessions.messages.subscribe { key, agentId?, includeApprovals: true }` でオプトインします。成功レスポンスには、購読クライアントがレコード単位でもレビューを認可されている、その完全一致ストリームキーに対する現在の保留中承認を最大 1,000 件含む `approvalReplay` が追加されます。`truncated: false` の場合、フィルタリング済みリプレイが権威を持ち、再接続したクライアントはローカルの保留中セットをそれで置き換えます。`truncated: true` は過負荷シグナルであり、クライアントは正規ルックアップまたは後続のライフサイクルイベントで決着するまで、未表示のローカルエントリを保持しなければなりません。リプレイ中に後から永続的なタイムアウトが検出された場合、新しいスナップショットを返す前に、購読中かつレコード単位で認可されたオーディエンスのみに終端トゥームストーンを発行します。`operator.admin` は直接オプトインできます。権限が限定されたクライアントには、ペアリング済みデバイス ID と `operator.approvals` の両方が必要です。セッション購読だけで承認の可視性が付与されることは決してありません。

`src/gateway/server-broadcast.ts` でイベントを `operator.approvals` 配下に登録します。このプロジェクションは観測専用です。トランスクリプト行を追加したり、`sessions.changed` を発行したり、エージェントを起動したりすることは決してありません。

`src/interactive/payload.ts` の `MessagePresentationAction` を拡張します：

```ts
type MessagePresentationAction =
  | { type: "command"; command: string }
  | { type: "callback"; value: string }
  | {
      type: "approval";
      approvalId: string;
      approvalKind: "exec" | "plugin";
      decision: ExecApprovalDecision;
    }
  | { type: "url"; url: string }
  | { type: "web-app"; url: string };
```

承認済みの絶対 Control UI オリジンを利用できる場合、コアは型付き決定アクションと独立したレビューリンクを構築します。チャネルは承認アクションを独自のコールバック形式にエンコードし、解決を正規サービスへ送信します。コールバックには、収まる場合は厳密な正規 ID を使用し、それ以外の場合は行の一意な完全ダイジェスト `resolution_ref` を使用します。この参照はコンパクトなルックアップキーにすぎません。通常の Gateway 認証、レコード認可、明示的な種別、許可された決定の検証、期限の整合、先着回答 CAS は引き続き適用されます。チャネルは ID を切り詰めたり、ハッシュプレフィックスを解決したり、`/approve` テキストを解析したり、ID プレフィックスから種別を推測したりしてはなりません。

`button.url`、`button.webApp`、コマンドを基盤とする承認コントロールは、非推奨の Plugin SDK 互換入力として維持します。SDK 境界で正規化し、同じ PR 内ですべてのバンドル済み内部呼び出し元を移行します。`/approve {id} {decision}` はテキストフォールバックおよび CLI／チャットコマンドとして残しますが、ボタンのセマンティック契約ではありません。

## Control UI

ルートは `${basePath}/approve/{approvalId}` です。ID が唯一のパスパラメーターであり、ソースセッション ID はレコードから取得します。

現在のルーターには完全一致の静的ルートがあり、不明なパスを Chat に書き換えるため、通常のルート正規化より前に `ui/src/app/bootstrap.ts` でこのディープリンクを検出します。通常の Gateway/認証セットアップを再利用しますが、サイドバーシェルとグローバルモーダルの外側にスタンドアロンの承認ページをレンダリングします。

このドキュメントは、その URL を配信した Gateway が所有します。初期接続では、フルアプリに保存されているリモート Gateway の選択を無視しますが、その選択の設定を変更またはコピーしません。認証のみが、配信元 Gateway に対するセッションスコープになります。信頼されたネイティブ認証、または別途確認された `gatewayUrl` オーバーライドによって、接続先を変更できます。コアは、Plugin HTTP ルートおよび静的拡張機能の検出より先に、1 セグメントの `/approve` 名前空間を予約します。これには、`.json` または `.js` で終わる ID も含まれます。Control UI の配信が無効な場合、予約済みルートはフェイルクローズし、`404` を返します。遅延読み込みチャンクの失敗によってセキュリティ判断がスピナー上で行き詰まらないよう、ページはメインの Control UI バンドルに含めます。

ページの状態:

- 読み込み中
- 認証が必要
- 保留中
- 解決中
- ここで承認または拒否済み
- 別の場所で解決済み
- 期限切れ
- キャンセル済み
- 禁止/見つからない
- 再試行可能な接続エラー

ページは、2 つ目の未認証 REST API ではなく Gateway RPC を呼び出します。ブラウザーを更新すると、永続状態を再読み込みします。Gateway の認証情報を URL、クエリ、フラグメントに配置することはありません。

## 認可とプライバシー

URL は位置を示すものであり、権限を与えるものではありません。解決には以下が必要です:

1. 認証済みの Gateway 接続。
2. `operator.approvals` または `operator.admin`。
3. レコード単位のレビュアー認可。

レコード単位のルール:

- `operator.admin` はレビューできます。
- `reviewer_device_ids` が存在する場合、それが権威を持ちます。リストに含まれる、ペアリング済みの
  `operator.approvals` デバイスのみがレビューできます。要求元デバイスもリストに含まれていない限り、
  暗黙のアクセス権はありません。
- 明示的なレビュアーリストがない場合、要求元のペアリング済み
  `operator.approvals` デバイスは、自身のレコードをレビューできます。
- 要求元またはレビュアーとの紐付けがない、真にレガシーなレコードでは、アップグレードによって
  すでに保留中の作業が行き詰まらないよう、ペアリング済みデバイスへの広い可視性を維持します。
- デバイスを持たない内部ランタイムは、スコープ付きの
  承認ランタイム接続を通じて解決できますが、読み取りはできません。その権限は、
  サーバー認証済みランタイムトークンからのみ得られます。公開された `approval.resolve` フィールドで
  その権限を作り出すことはできません。
- ライブな要求元接続の所有権は、レガシーアダプターに対して引き続き有効です。一致するクライアント名から
  推測されることは決してありません。
- オーディエンスのメンバーシップは表示のみを変更します。認可を拡大することはありません。

`approval.get` は、サニタイズ済みのレビュアー向けプロジェクションのみを公開し、内部のソース/オーディエンスルーティングキーを省略します。PR 5 の `session.approval` イベントは、Gateway が永続化済みのオーディエンススナップショットをサーバー側で適用した後、1 つの宛先 `sessionKey` と `sourceSessionKey` を伝達します。既存の exec/Plugin イベントは、コンシューマーが移行するまで、従来のペイロードと制限された受信者を維持します。実行可能なリクエスト、コマンドの紐付け、継続処理は、プロセスローカルの待機処理内にのみ残ります。永続行には、安全な表示情報に加えて、ライフサイクル、ルーティング、監査のメタデータが含まれます。生の環境値、認証情報、認証ヘッダー、チャネルのコールバックデータは保存しません。

## オーディエンスのプロジェクション

挿入前にオーディエンスを一度だけ計算し、順序付きスナップショットを永続化します。所有権はグラフであり、常に単一の親チェーンとは限りません。子は現在のコントローラーと元の要求元の両方を持つ場合があり、それらの所有者が異なるルートにつながることがあります。

決定論的な幅優先探索を使用します:

1. ソースセッションキーをキューの初期要素にします。
2. デキューした各キーについて、最新のサブエージェントレジストリ行を読み取り、異なる 2 つの所有権エッジを固定順序でエンキューします。順序は `controllerSessionKey`、次に `requesterSessionKey` です。
3. 使用可能なレジストリ行が存在する場合、ステアリング後に古くなっている可能性があるセッションエントリの系譜は追加でたどりません。それ以外の場合は、現在の単一のフォールバックエッジ `parentSessionKey ?? spawnedBy` をエンキューします。
4. エンキュー時に正規化と重複排除を行い、最初に見つかった最短パスを優先します。
5. 一意なキーが 64 個に達した時点で停止します。このオーディエンスサイズの上限は、探索の深さも制限します。

レジストリのソースは `src/agents/subagent-registry-read.ts` であり、所有権フィールドは `src/agents/subagent-registry.types.ts` で定義されています。セッションのフォールバックフィールドは `src/config/sessions/types.ts` で定義されています。

要求時と終端時のプロジェクションでは、承認の保留中にフォーカス/コントローラーの所有権が変わった場合でも、同じ永続化済みオーディエンスを使用します。これにより、要求プロジェクションを受信したすべてのオーディエンスセッションストリームで終端クリーンアップが保証されます。解決は常にソース承認 ID を対象とします。オーディエンスセッションが複製された承認状態を受け取ることはありません。転送されたチャネルメッセージのクリーンアップは、後述する別の配信ロケーターのフォローアップとして残ります。

承認だけを理由に、トランスクリプトメッセージの書き込み、システムプロンプトの注入、所有者ターンの開始、または `sessions.changed` の発行を行わないでください。

## 配信面の収束

ネイティブ承認ハンドラーは、アクティブなコントロールを置換または無効化できるだけの期間、配信済みメッセージのエントリをすでに保持しています。汎用の転送済み承認メッセージは現在 `MessageReceipt` を破棄するため、別の画面で決定すると、古いコントロールが保留中に見えるまま残る可能性があります。別のフォローアップとして、共有状態データベースに `operator_approval_deliveries` 子テーブルを追加し、この不足を解消します。

各行には、承認 ID、一意の配信 ID、チャネル/アカウント/正確なルート、サイズ制限付きで JSON 検証済みのチャネル専用メッセージロケーター、配信タイムスタンプ、終端化状態を保存します。コールバックデータ、決定トークン、生の承認リクエストは保存しません。チャネルはロケーターのエンコードとメッセージ変更を所有し、コアは正規の状態、対象の選択、再試行ポリシー、フォールバックの終端テキストを所有します。

配信登録と終端解決は競合しても安全です:

1. 保留中メッセージの送信がレシートを返した後、1 つのトランザクション内で配信ロケーターを挿入し、親承認の状態を読み取ります。
2. 親がすでに終端状態である場合、遅れて到着した配信を保留中のまま残さず、即時の終端化をスケジュールします。
3. コミットされたすべての終端遷移は、未完了の配信行を個別にスケジュールします。破棄可能なブロードキャストはトリガーではありません。
4. チャネルの終端化処理は、`replaced`、`retired`、または `unsupported` を報告します。Replaced の場合は重複する終端メッセージを抑制し、retired の場合は既存の終端フォローアップを送信します。unsupported または失敗の場合は、承認 CAS をロールバックせずにフォールバックします。
5. 起動時に、未完了の配信を持つ終端済み承認を再試行し、Gateway の再起動に対してクリーンアップを堅牢にします。

このトランスポートライフサイクルは、任意の配信アダプターフックであり、レンダラーやモデル向けのメッセージアクションではありません。QQ の C2C/グループメッセージには現在、編集、削除、キーボード消去の API がありません。そのアダプターは未対応のままであり、トランスポートに変更 API が追加されるまでは、後からクリックされたときにのみ正規の真実を表示できます。

## 再起動、タイムアウト、ルートのセマンティクス

SQLite への永続化は、実行の再開を意味しません。コマンド/ツールの紐付けにはセキュリティ上機微なランタイム情報が含まれる場合があり、再開可能なジョブ契約ではないため、メモリ内に残します。

Gateway の起動時:

- 新しいランタイムエポックを生成します。
- 古いエポックの保留中行を、理由 `gateway-restart` の `cancelled` にアトミックに遷移させます。
- URL で何が起きたかを説明できるよう、行を保持します。
- ランタイムの紐付けが存在しない承認を、後から実行することは決してありません。

タイマーはウェイクアップの最適化です。期限の権威は保存された `expires_at_ms` にあり、読み取り、待機、解決ではすべて期限切れの整合処理を実行します。

最終的な厳格動作:

- タイムアウト -> `expired`、拒否。
- ルートなし -> `denied`、拒否。
- 実行中止 -> `cancelled`、拒否。
- 不正な信頼済み判定 -> `denied`、拒否。
- 許可された明示的な許可決定のみ -> `allowed`。

現在リリース済みの exec の動作は、依然としてこの契約と矛盾しています:

- `src/agents/bash-tools.exec-host-shared.ts` は `askFallback` を適用する場合があります。
- `docs/tools/exec-approvals.md` と `docs/cli/approvals.md` は、その動作を文書化しています。

Plugin の承認は、タイムアウトおよび不正な判定時にフェイルクローズするようになりました。レガシーの
`timeoutBehavior` フィールドは引き続き受け付けますが、無視されます。exec の厳格セマンティクスに関する
フォローアップでは、コード、型、ドキュメント、テスト、変更履歴を同時に更新し、
所有者およびセキュリティ担当者による明示的なレビューを受ける必要があります。移行中、`askFallback` は
ゲート前のポリシー選択を引き続き表しても構いませんが、作成済みの
保留中レコードのタイムアウトを承認に変えてはなりません。

## 互換性計画

- Gateway プロトコルへの追加のみとし、プロトコルのバージョンは上げません。
- 外部境界では、既存の exec/Plugin のメソッドとイベントを維持します。
- `plugin:` プレフィックスを含む既存の ID を維持しますが、プレフィックスを型情報として使用することは停止します。
- `/approve` テキストコマンドの動作を維持します。
- レガシーのボタン URL/Web App フィールドとコマンドアクションは、Plugin SDK の互換入力として維持します。新しいコア出力は型付きにします。
- すべての同梱チャネルと内部呼び出し元を、同じ型付きアクションの変更内で移行します。
- 新しい URL/ページと、後続のタイムアウト動作変更について、変更履歴のエントリを追加します。
- 引き出しモードの設定は追加しません。

## ロールアウト

### PR 1: 永続ライフサイクル

- この設計メモ。
- 共有 SQLite スキーマ、Kysely 生成、ストア、30 日間のプルーニング。
- Gateway 承認サービス、ランタイム待機処理のブリッジ、再起動時の孤立処理。
- 統合された `approval.get/resolve`。
- exec/Plugin メソッドアダプター。
- 最初の回答を優先する動作、冪等性、期限切れ、認可、消費に関するテスト。
- この段階では UI またはチャネルの動作は変更しません。

### PR 2: 型付きアクションとチャネルコールバック

- 型付きの承認、URL、Web App アクション。
- コアの表示ビルダーと Plugin SDK のエクスポート。
- 明示的な所有者種別を持つ、トランスポート専用のコールバックエンコード。
- トランスポートの制限を超える正規 ID 向けの、永続的な固定サイズコールバック参照。
- コマンドテキストおよび承認 ID の推測を廃止する、同梱チャネルの移行。
- クリックされた画面上での正規の最初の回答の真実と、ベストエフォートによるアクティブなネイティブ終端更新。永続的なチャネルメッセージ終端化はフォローアップとして残します。
- SDK および同梱チャネルのテスト。

### PR 3: Control UI ディープリンク

- スタンドアロンの認証済み承認ページと、ベースパスを考慮した起動時ルーティング。
- オペレーターが保存したリモート選択を変更せずに行う、配信元 Gateway への紐付け。
- アセットに似た ID を含む、コア所有の承認 HTTP 名前空間。
- Gateway が生成する URL ペイロードと、ライフサイクルイベントが提供されるまでの保留状態ポーリング。
- モバイル幅、再接続、競合回答、再読み込み、マウント済みパスの検証。

### PR 4: ネイティブクライアント

- iOS と Android のレビュー画面は、種別を考慮した `approval.get/resolve` を使用します。watchOS は、ペアリングされた iPhone を通じて、レビュアー向けに安全なプロンプトと決定を中継します。
- Watch は、コンパクトな中継契約でサポートされる exec の決定、つまり 1 回のみ許可と拒否を提供します。
- 正規の最初の回答の終端状態が、ローカルの決定試行状態を置き換えます。
- 解決の確認応答が失われた場合、または曖昧な場合は、正規状態を再読み取りするまでコントロールを凍結します。
- 以前リリースされた Gateway v4 インスタンスでは、限定的なレガシーメソッドへのフォールバックによって exec のレビューを維持します。画面間で維持される終端状態には、統合メソッドが必要です。
- レビュアー向けの警告と所有者コンテキストは、iPhone、Watch、Android のすべてで引き続き表示されます。
- ネイティブの単体テスト、ビルド、プラットフォーム検証。

### PR 5: 祖先へのライフサイクル伝播

- PR 1 で永続化されたオーディエンススナップショットからの、`session.approval` の保留中/終端配信。
- トランスクリプトの変更やエージェントのウェイクを伴わない、正確なセッション購読、再接続時のリプレイ、終端トゥームストーン。
- ライフサイクルコールバックは永続的な挿入/CAS の後に実行し、承認の権威には決してなりません。
- ネストされたサブエージェントと再接続の検証。

### PR 6: フェイルクローズ動作

- `node-invoke-plugin-policy.ts` と組み込み Plugin ブローカーを、権限の重複から移行します。
- タイムアウト、不正な値、ルートなし、紐付け、1 回のみ許可の消費に関する厳格なセマンティクス。
- ask が保留状態になった後は適用せず、リリース済みの寛容なタイムアウト設定を非推奨化します。
- 複数画面間の競合と障害注入の検証。

### フォローアップ: 永続的なリモートメッセージのクリーンアップ

- 転送配信のロケーターを永続化し、再起動後に配信済みのすべてのチャネルメッセージを終端状態にする。
- このトランスポートのライフサイクルを、正規の承認権限および型付きプレゼンテーションアクションから分離して維持する。

## テスト

必須の重点的なカバレッジ：

- SQLite を再度開いても、保留中および終端状態のプロジェクションが保持される。
- 2 つの並行リゾルバーから、CAS の勝者が正確に 1 つだけ生成される。
- 同じ判断の再試行は冪等に成功し、競合する再試行は記録済みの勝者を返す。
- 期限時刻以降の解決では承認できない。
- `allow-once` は終端監査状態を消去せず、正確に 1 回だけ消費できる。
- 起動時に古いランタイムエポックをキャンセルする。
- 権限のない検索および解決では、レコードの存在を明かさない。
- 明示的なレビュアー許可リストと、一般的なペアリング済み `operator.approvals` の動作。
- Exec と Plugin のレガシーメソッドが同じストアを共有する。
- Gateway の request/list/get/resolve スキーマと、追加的なイベントペイロード。
- 型付きアクションの正規化、フォールバックレンダリング、SDK エクスポート、および同梱チャネルの切り替え。
- Telegram のコールバックエンコーディングにはトランスポート固有の非公開データが含まれ、コマンド文字列の推論は含まれない。
- 直接の子、分岐したコントローラー／リクエスターの所有者、ネストされた所有者、再割り当て、セッションフィールドのフォールバック、循環、およびオーディエンスサイズの上限。
- リクエスト時と終端時のオーディエンス配列が同一である。
- 所有者プロジェクションによってトランスクリプトが変更されたり、エージェントが起動されたりしない。
- Control UI のルートが `/` および構成済みのベースパスで動作し、更新後に保留中または終端状態の正しい情報が表示される。
- Control UI と Telegram から同時に回答した場合、一方のみが勝者となり、敗者には「他で解決済み」と表示される。
- ネイティブ承認識別子と Gateway 所有者識別子で、ルーティングおよび照合の全過程を通じて正確な UTF-8 バイト列が保持される。
- ネイティブ RPC ファミリーのネゴシエーションでは、許可された Gateway ルートごとに正規またはレガシーのいずれか 1 つのファミリーに固定され、使用後に暗黙的にダウングレードされない。
- ネイティブ解決の確認応答が失われた場合、正規の読み戻しが完了するまでアクションを停止する。読み戻しに失敗した場合、勝者を捏造したり Watch の更新を確認済みとして応答したりできない。
- Watch のスナップショットリクエスト相関は、正確にペアリングされた Gateway 所有者と、完了した正規の iPhone 読み戻しに対してのみ受け入れられる。
- モバイル幅の承認ページ、Telegram アクションのクリーンアップ、および Android、iPhone、Watch をまたぐ保留／解決／遅延した敗者の 1 往復を含む、Testbox/Crabbox でのユーザーパスの実証。

## オブザーバビリティ

承認 ID、種類、ソースセッションキー、ステータス、理由、レイテンシーを含む、コンテンツを含まない構造化された遷移ログを出力する。プレビューまたは生のバインディングは決してログに記録しない。

次を追跡する：

- 種類別のリクエスト数。
- 種類／ステータス／理由別の終端数。
- 保留中ゲージ。
- リクエストから終端までのレイテンシー。
- 解決競合の結果：勝者、冪等な再試行、競合、期限切れ。
- 配信ルート数と、ルートなしによる拒否。
- 起動時の孤立状態のキャンセル。
- オーディエンスサイズ。

コミット済みの遷移は、その後のイベント配信に失敗しても成功とする。ライフサイクルのサブスクライバーは、PR 5 のリプレイと正規の検索を通じて復旧する。永続的なチャネルメッセージの終端化は、上記の別個のフォローアップとして引き続き扱う。

## 未決事項

1. **外部から到達可能な Control UI のオリジン。** すべてのスナップショットには、安定した相対 `urlPath` が含まれる。絶対 URL を通知できるのは、Gateway の公開に成功した後、キャッシュされた Tailscale Serve/Funnel の場所からのみとする。`allowedOrigins`、リクエストの Host ヘッダー、`gateway.remote.url`、および表示専用のループバック／LAN 候補は正規のオリジンではない。Telegram は、認証済みの Mini App ラッパーを使用して、ブートストラップを通じて承認パスを維持できる。任意のリバースプロキシについては、別途レビューされた明示的な公開 URL の契約が存在するまで、相対 URL のみを使用する。チャネルにオリジンを推測させてはならない。
2. **Exec の厳格なタイムアウト互換性の切り替え。** Plugin の承認タイムアウトは現在、フェイルクローズとなり、`timeoutBehavior` は非推奨になっている。残るリリース済みの `askFallback` 契約が、保留中の確認のタイムアウト後に実行を許可しなくなる前に、明示的な所有者／セキュリティレビュー、変更履歴、ドキュメント、および移行／非推奨化の判断が必要である。
3. **Gateway なしの組み込みモード。** 推奨：当初はローカル専用とし、Gateway が存在する場合は正規サービスのクライアントにする。解決できるサーバーが存在しないディープリンクを通知しない。
