レコード変更に承認を必須にする

CloudflareBeginner
オンラインで実践に進む

はじめに

検証済みのツールでも、実行が早すぎる場合があります。モデルがレコード変更を提案した場合、書き込みを行う前に、担当者が具体的な操作内容を確認する必要があります。human-in-the-loop approval(人間参加型の承認)を使うと、ツール呼び出しを一時停止し、クライアントに引数を表示して、担当者が承認または拒否できます。

この実験では、小さな合成サポート Agent を拡張し、読み取りツールと、承認が必要な更新処理を 1 つずつ追加します。

  1. lookupSupportCase は読み取り専用のサーバーツールとして残り、承認なしで実行されます。
  2. requestPriorityChange はサポートされている needsApproval オプションを使用します。そのため、クライアントが承認応答を送信するまで、その execute 関数は実行されません。
  3. React クライアントは保留中の操作を表示し、承認または拒否のために addToolApprovalResponse() を呼び出します。
  4. 永続的な冪等性台帳に操作キーを記録します。同じ承認済み操作が繰り返し配信された場合、2 回目の更新を適用せず、最初の結果を返します。
  5. 決定的なプローブと、1 回に制限した Workers AI のフローで、保留、拒否、承認、重複の各結果を確認します。

承認と認可は、異なる問題に答えます。認可は、署名済みセッションがアクセスできるキューを制限します。一方、承認は、担当者が今回提案された具体的な変更を受け入れるかどうかを確認します。冪等性は 3 つ目の問題に対処します。ネットワークやクライアントが、同じ承認済み操作を複数回配信する可能性があるためです。この実験のレコードはすべて合成データであり、破棄可能です。実際のヘルプデスクシステムには接続しません。

用意されているシェルと短期間だけ有効なセッショントークンにより、フロントエンドや認証の定型処理ではなく、承認境界に集中できます。Workers AI の無料割り当ては、同じアカウントの他のアクティビティと共有されます。アカウントに割り当てが残っていない場合は、有料プランを有効にせず中止してください。

このコースに直接入る前に、LabEx を Cloudflare アカウントに接続するを完了してください。 新しい LabEx VM では、それぞれに独自の Wrangler 認可が必要です。コース内の以前の実験は推奨されますが、以前の VM やリソースがここで再利用されることはありません。

VM を認可し、承認用 Worker を宣言する

このステップでは、新しい VM を認可し、承認が必要な Agent が使用するリソースを宣言します。

ターミナルを開き、用意されたプロジェクトに移動します。

cd /home/labex/project/approval-record-changes

この VM を認可します。

npx wrangler login

表示されたリンクを開き、専用の学習用アカウントに対して説明されている Wrangler の権限を承認してから、ターミナルに戻ります。構造化された結果を確認します。

npx wrangler whoami --json

"loggedIn": true を探し、アカウント名を確認して、そのアカウントの実際の ID をコピーします。固有の破棄可能な Worker 名と一緒に保存します。

ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s06-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true },
  "ai": { "binding": "AI", "remote": true },
  "durable_objects": {
    "bindings": [
      { "name": "ApprovalAgent", "class_name": "ApprovalAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["ApprovalAgent"] }
  ]
}
JSON
python3 .labex/verify.py authorization

AI バインディングは、API キーを埋め込まずにモデル推論を提供します。Durable Object バインディングにより、名前付きの各 ApprovalAgent が、合成ケースと冪等性台帳用の独自 SQLite ストレージを持ちます。ブラウザーでは planning という名前を使用します。別の名前を使うと別のインスタンスになり、planning のデータは見えません。まだ何もデプロイされていません。

ツールの契約を定義する

このステップでは、各ツールが受け付ける引数を正確に定義します。

ツールスキーマは、実行時の契約です。コンパイル時には TypeScript の型が役立ちますが、モデルの出力は実行時に届くため、もう一度検証する必要があります。src/cases.ts を作成します。

cat > src/cases.ts <<'TS'
import { z } from "zod";

const queue = z.string()
  .min(3)
  .max(40)
  .regex(/^[a-z0-9-]+$/, "queue must use lowercase letters, digits or hyphens");

export const lookupCaseInput = z.object({
  queue,
  ticketId: z.literal("T-SYNTH-101")
}).strict();

export const changePriorityInput = lookupCaseInput.extend({
  priority: z.literal("high"),
  operationKey: z.string()
    .min(8)
    .max(80)
    .regex(/^[a-z0-9-]+$/, "operation key must use lowercase letters, digits or hyphens")
}).strict();

export type LookupCaseInput = z.infer<typeof lookupCaseInput>;
export type ChangePriorityInput = z.infer<typeof changePriorityInput>;
export type SupportCase = {
  queue: string;
  ticketId: "T-SYNTH-101";
  summary: string;
  priority: "low" | "medium" | "high";
  revision: number;
};
export type ChangeResult = SupportCase & { duplicate: boolean; operationKey: string };

export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
  const result = schema.safeParse(input);
  if (!result.success) {
    const issue = result.error.issues[0];
    throw new Error(`invalid tool input: ${issue.path.join(".") || "request"} ${issue.message}`);
  }
  return result.data;
}
TS
python3 .labex/verify.py schemas

読み取り用の契約では、有効なキュー名と、1 件の合成チケットだけを受け付けます。変更用の契約では、この実験を優先度 high への変更に限定し、安定した operationKey を必須にします。.strict() により予期しないフィールドも拒否されます。これにより曖昧さを減らし、呼び出し元がサポートされていない指示を操作に紛れ込ませることを防ぎます。

冪等性キーは、再試行をまたいで 1 つの論理操作を識別します。サーバーは、そのキーに対して最初に成功した結果を保存します。同じ承認済み操作を再度受け取った場合は、2 回目の書き込みを行わず、保存済みの結果を返します。検証によって承認やアクセス権が与えられるわけではありません。Agent は引き続き永続的な名前を確認し、SDK は人間の判断を待ちます。

承認ゲートと冪等な効果を実装する

このステップでは、読み取りを自動実行し、needsApproval で書き込みを一時停止し、承認済みの効果を冪等にします。

src/server.ts を作成します。

cat > src/server.ts <<'TS'
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import { callable, routeAgentRequest } from "agents";
import { convertToModelMessages, stepCountIs, streamText, tool } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import {
  changePriorityInput,
  lookupCaseInput,
  parseInput,
  type ChangePriorityInput,
  type ChangeResult,
  type LookupCaseInput,
  type SupportCase
} from "./cases";
import { verifySessionRequest } from "./session-auth";

export class ApprovalAgent extends AIChatAgent<Cloudflare.Env> {
  maxPersistedMessages = 12;

  private ensureTables(): void {
    this.sql`CREATE TABLE IF NOT EXISTS support_cases (
      ticket_id TEXT PRIMARY KEY,
      queue TEXT NOT NULL,
      case_summary TEXT NOT NULL,
      priority TEXT NOT NULL,
      revision INTEGER NOT NULL
    )`;
    this.sql`INSERT OR IGNORE INTO support_cases
      (ticket_id, queue, case_summary, priority, revision)
      VALUES ('T-SYNTH-101', ${this.name}, 'Synthetic customer cannot open a sample invoice', 'medium', 0)`;
    this.sql`CREATE TABLE IF NOT EXISTS approval_operations (
      operation_key TEXT PRIMARY KEY,
      ticket_id TEXT NOT NULL,
      applied_revision INTEGER NOT NULL
    )`;
  }

  private scopedCase(input: LookupCaseInput): SupportCase {
    if (input.queue !== this.name) throw new Error("queue is outside this Agent scope");
    this.ensureTables();
    const rows = this.sql<{
      queue: string;
      ticketId: "T-SYNTH-101";
      summary: string;
      priority: "low" | "medium" | "high";
      revision: number;
    }>`SELECT queue, ticket_id AS ticketId, case_summary AS summary, priority, revision
       FROM support_cases WHERE ticket_id = ${input.ticketId}`;
    const record = rows[0];
    if (!record || record.queue !== this.name) throw new Error("case not found in this Agent scope");
    return record;
  }

  @callable()
  inspectCase(input: unknown): SupportCase {
    return this.scopedCase(parseInput(lookupCaseInput, input));
  }

  private applyApprovedChange(input: unknown): ChangeResult {
    const parsed: ChangePriorityInput = parseInput(changePriorityInput, input);
    const current = this.scopedCase(parsed);
    const prior = this.sql<{ appliedRevision: number }>`SELECT applied_revision AS appliedRevision
      FROM approval_operations WHERE operation_key = ${parsed.operationKey}`[0];
    if (prior) {
      return { ...current, duplicate: true, operationKey: parsed.operationKey };
    }
    this.sql`UPDATE support_cases
      SET priority = ${parsed.priority}, revision = ${current.revision + 1}
      WHERE ticket_id = ${parsed.ticketId} AND queue = ${this.name}`;
    const changed = this.scopedCase(parsed);
    this.sql`INSERT INTO approval_operations (operation_key, ticket_id, applied_revision)
      VALUES (${parsed.operationKey}, ${parsed.ticketId}, ${changed.revision})`;
    console.log(JSON.stringify({
      event: "approval_change_applied",
      instance: this.name,
      operationKey: parsed.operationKey,
      revision: changed.revision
    }));
    return { ...changed, duplicate: false, operationKey: parsed.operationKey };
  }

  @callable()
  async verifyApprovedChange(input: unknown, proof: string): Promise<ChangeResult> {
    const parsed = parseInput(changePriorityInput, input);
    const payload = new TextEncoder().encode(`approval-probe:${JSON.stringify(parsed)}`);
    const key = await crypto.subtle.importKey(
      "raw",
      new TextEncoder().encode(this.env.SESSION_SIGNING_KEY),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["verify"]
    );
    const normalized = proof.replace(/-/g, "+").replace(/_/g, "/");
    const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
    const signature = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
    const valid = await crypto.subtle.verify("HMAC", key, signature, payload);
    if (!valid) throw new Error("approval probe proof is invalid");
    return this.applyApprovedChange(parsed);
  }

  async onChatMessage(_onFinish: unknown, options?: OnChatMessageOptions) {
    const tools = {
      lookupSupportCase: tool({
        description: "Read synthetic ticket T-SYNTH-101 only from the current named support queue.",
        inputSchema: lookupCaseInput,
        execute: async (input) => this.inspectCase(input)
      }),
      requestPriorityChange: tool({
        description: "Set synthetic ticket T-SYNTH-101 to high priority in the current queue. Use operation key raise-synthetic-priority.",
        inputSchema: changePriorityInput,
        needsApproval: true,
        execute: async (input) => this.applyApprovedChange(input)
      })
    };
    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({
      model: workersai("@cf/zai-org/glm-4.7-flash", {
        reasoning_effort: null,
        chat_template_kwargs: { enable_thinking: false }
      }),
      system: `You assist only the synthetic ${this.name} queue. Perform exactly the one action the user requests. For a lookup, call lookupSupportCase only. For a priority request, call requestPriorityChange only with operationKey raise-synthetic-priority and wait for the human decision. Never claim a denied or pending change happened. Keep the final answer to one short sentence.`,
      messages: await convertToModelMessages(this.messages),
      tools,
      stopWhen: stepCountIs(4),
      maxOutputTokens: 96,
      temperature: 0,
      abortSignal: options?.abortSignal
    });
    return result.toUIMessageStreamResponse();
  }
}

export default {
  async fetch(request: Request, env: Cloudflare.Env): Promise<Response> {
    const authorize = (candidate: Request, route: { name: string }) =>
      verifySessionRequest(candidate, route.name, env.SESSION_SIGNING_KEY);
    return (await routeAgentRequest(request, env, {
      onBeforeConnect: authorize,
      onBeforeRequest: authorize
    })) ?? new Response("Not found", { status: 404 });
  }
};
TS
python3 .labex/verify.py server

モデルがデータベースに直接アクセスすることはありません。モデルは型付きの引数を提案しますが、needsApproval: true によって、クライアントが承認応答を送信するまで execute の実行を防ぎます。したがって、拒否してもメソッドは変更されません。読み取り専用の callable は、決定的な確認に使用できます。別の検証用 callable は、ローカルの署名シークレットから生成した HMAC 証明がある場合にだけ効果へ到達できます。そのため、通常のブラウザクライアントが人間の承認ゲートを回避することはできません。

Agent は各同期 callable 呼び出しを await なしで処理するため、2 回目の配信では 1 回目に作成された台帳行が確認されます。安定した操作キーが、すでに適用された結果を duplicate: true とともに返し、レコードを再度増分することはありません。ログに記録されるのは、イベント、Agent インスタンス、操作キー、リビジョンだけです。ケース本文は記録されません。

人間による承認判断を表示する

このステップでは、保留中のツール呼び出しを、暗黙的に実行するのではなく、明示的な判断として表示します。

TypeScript と Vite の設定を作成します。

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["@cloudflare/workers-types", "vite/client", "node"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON

cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import agents from "agents/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [react(), agents(), cloudflare()] });
TS

src/client.tsx を作成します。

cat > src/client.tsx <<'TSX'
import { getToolApproval, useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import { getToolName, isToolUIPart } from "ai";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";

function ApprovalChat() {
  const parameters = new URLSearchParams(window.location.search);
  const session = parameters.get("session") ?? "";
  const token = parameters.get("token") ?? "";
  if (!session || !token) {
    return <main><h1>Signed session required</h1><p className="help">Open the complete URL printed by the token command.</p></main>;
  }

  const agent = useAgent({
    agent: "ApprovalAgent",
    name: session,
    host: window.location.host,
    query: { token }
  });
  const { messages, sendMessage, addToolApprovalResponse, status, error } = useAgentChat({
    agent,
    autoContinueAfterToolResult: false
  });

  return (
    <main>
      <p className="eyebrow">Human approval before effect</p>
      <h1>Synthetic Change Review</h1>
      <p className="scope">Allowed queue: <strong>{session}</strong> · allowed ticket: <strong>T-SYNTH-101</strong></p>
      <p className="status">Status: <strong>{status}</strong></p>
      <section className="messages" aria-live="polite">
        {messages.length === 0 && <p className="empty">No change request in this signed session yet.</p>}
        {messages.map((message) => (
          <article className={`message ${message.role}`} key={message.id}>
            <span className="role">{message.role}</span>
            {message.parts.map((part, index) => {
              if (part.type === "text") return <span key={index}>{part.text}</span>;
              if (isToolUIPart(part)) {
                const toolName = getToolName(part);
                if ("approval" in part && part.state === "approval-requested") {
                  const approvalId = getToolApproval(part)?.id;
                  return (
                    <div className="approval" key={part.toolCallId}>
                      <strong>Approval required: {toolName}</strong>
                      <p>Review these exact synthetic arguments. No record has changed yet.</p>
                      <pre>{JSON.stringify(part.input, null, 2)}</pre>
                      <div className="approval-actions">
                        <button disabled={!approvalId} () => {
                          if (!approvalId) return;
                          await addToolApprovalResponse({ id: approvalId, approved: true });
                          sendMessage();
                        }}>Approve</button>
                        <button className="deny" disabled={!approvalId} => approvalId && addToolApprovalResponse({ id: approvalId, approved: false })}>Deny</button>
                      </div>
                    </div>
                  );
                }
                return (
                  <div className="tool-card" key={part.toolCallId}>
                    <strong>{toolName}</strong><span className="tool">{part.state}</span>
                    {"output" in part && part.output !== undefined && <pre>{JSON.stringify(part.output, null, 2)}</pre>}
                  </div>
                );
              }
              return null;
            })}
          </article>
        ))}
      </section>
      <div className="quick-actions">
        <button type="button" disabled={status === "streaming" || status === "submitted"} => sendMessage({ text: `Look up T-SYNTH-101 in ${session}.` })}>Check current case</button>
        <span>Read-only: safe before and after a decision.</span>
      </div>
      <form => {
        event.preventDefault();
        const input = event.currentTarget.elements.namedItem("message") as HTMLInputElement;
        const text = input.value.trim();
        if (!text) return;
        sendMessage({ text });
      }}>
        <input name="message" defaultValue={`Request high priority for T-SYNTH-101 in ${session} with operation key raise-synthetic-priority.`} maxLength={220} aria-label="Change request" />
        <button type="submit" disabled={status === "streaming" || status === "submitted"}>Send</button>
      </form>
      <p className="notice">Training fixture only: this page cannot reach a real support system.</p>
      {error && <p className="error" role="alert">{error.message}</p>}
    </main>
  );
}

createRoot(document.getElementById("root")!).render(
  <Suspense fallback={<main><p>Restoring the signed approval session…</p></main>}><ApprovalChat /></Suspense>
);
TSX
python3 .labex/verify.py client

useAgent() は、短期間だけ有効なトークンを使って、名前付きの 1 つの Agent にだけ接続します。読み取り専用ボタンと変更フォームは、意図的に別々のターンを作成します。各ターンの目的を 1 つにすることで、すでに完了した読み取りツールと保留中の書き込みを混在させず、判断の前後の状態を確認できます。useAgentChat() は承認応答ヘルパーを提供します。Agents クライアントは、その判断をサーバーに通知します。ここでは自動的なクライアント継続を無効にしているため、同じ承認済みツール呼び出しが 2 回送信されることはありません。ツールの結果と新しい読み取り結果を使うことで、モデルが生成した追加の要約文よりも確実に確認できます。isToolUIPart() は通常のアシスタントテキストとツール操作を区別し、getToolApproval() は SDK がサポートするインターフェイスを通じて承認オブジェクトを読み取ります。承認 ID により、担当者の判断がこの具体的なツール呼び出しに結び付けられます。ブラウザーがデータベースメソッドを直接呼び出すことはありません。JSON カードにより、アカウント認証情報を公開せずに、読み取り結果、提案された引数、最終的な書き込み結果を確認できます。

ローカルでビルドし、境界を検証する

このステップでは、アプリケーションをコンパイルし、モデル呼び出しを消費せずに実際のツール実装をテストします。

正確な環境型を生成し、型チェックと 2 つのバンドルのビルドを実行します。

npx wrangler types
npm run check
npm run build
python3 .labex/verify.py build

Wrangler は実際のバインディングから Cloudflare.Env を生成します。これにより、手書きの環境インターフェイスが wrangler.jsonc とずれることを防ぎます。

Workers AI はリモートバインディングであるため、ローカルランタイムでは Wrangler が保存した OAuth アクセスを使用する必要があります。トークンは子プロセスにだけ渡し、シェル内のコピーはすぐに消去します。

DEV_PROXY_TOKEN="$(npx wrangler auth token --json | node -e 'let data="";process.stdin.on("data",chunk=>data+=chunk).on("end",()=>process.stdout.write(JSON.parse(data).token))')"
CLOUDFLARE_API_TOKEN="$DEV_PROXY_TOKEN" CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
unset DEV_PROXY_TOKEN
for attempt in $(seq 1 40); do
  curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
  sleep 1
done
tail -n 12 .labex/dev.log
python3 .labex/verify.py local

一時的な OAuth 値を表示したり、.dev.vars に保存したりしないでください。独立したプローブは、ランダムな名前の Agent、読み取り専用の inspectCase() メソッド、ローカルの署名シークレットから生成したテスト専用 HMAC 証明を使用します。この証明により、承認済みモデルツールが使用する同じ非公開の効果を、承認を回避する callable を公開せずに検証できます。これにより、モデルの動作とは独立して、効果のサーバー側の安全性を確認できます。

  • 初期状態では、優先度が medium、リビジョンが 0 である。
  • キューをまたいだ読み取りが失敗する。
  • 承認済みの効果を 1 回呼び出すと、優先度が high、リビジョンが 1 になる。
  • 同じ操作キーでは duplicate: true が返り、リビジョンは 1 のままである。
  • 別の名前の Agent には、分離されたリビジョン 0 のレコードが残る。

この決定的なテストにより、繰り返し配信が安全かどうかを確認できます。ブラウザーフローでは、サポートされている SDK のゲートが、承認前と拒否時に効果を防ぐことを別途確認します。

デプロイし、拒否・承認・再送を確認する

このステップでは、デプロイ後、同じ変更提案が保留され、拒否され、1 回だけ承認され、繰り返しても安全であることを確認します。

本番用バンドルをデプロイし、生成された署名キーをシークレットとしてアップロードします。

npm run deploy
npx wrangler secret bulk .dev.vars

シークレットコマンドは、値を設定やバンドルに含めずに送信します。.dev.vars を表示しないでください。

デプロイ時に表示された正確なオリジンを保存し、planning 用に 10 分間有効なトークンを作成します。

WORKER_URL="https://paste-the-workers-dev-origin-printed-by-deploy"
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf '%s/?session=planning&token=%s\n' "${WORKER_URL%/}" "$TOKEN"

完全な URL を LabEx ブラウザーで開きます。最初に Check current case を選択します。読み取り専用の結果に、優先度 medium、リビジョン 0 と表示されます。次に、用意された変更リクエストを送信します。この 2 回目の単一目的のターンは、Approval required で停止します。判断する前は、サーバー側の execute 関数は実行されていません。

効果が適用される前に一時停止した合成優先度変更

Deny を選択します。ツールは拒否状態になり、Agent は更新が行われたと報告してはいけません。もう一度 Check current case を選択します。新しい読み取り結果にも優先度 medium、リビジョン 0 と表示され、拒否された実行で変更が発生していないことを確認できます。用意された変更リクエストを再度送信し、新しい承認カードを表示します。

拒否後も変更されていないレコードと、新たに保留された判断

この 2 回目のリクエストで Approve を選択します。サポートされているクライアントが承認 ID を Agent に返し、サーバーが 1 回だけ実行します。結果には、優先度 high、リビジョン 1duplicate: false と表示されます。Check current case を選択して、同じリビジョンを独立して確認します。

承認された合成変更が 1 回だけ適用された状態

同じ変更リクエストを 3 回目に送信して承認します。永続的な台帳が raise-synthetic-priority を認識し、結果に duplicate: true と表示されます。もう一度 Check current case を選択すると、レコードはリビジョン 1 のままです。

承認済みの再送で最初の結果が返され、追加の書き込みが行われていない状態

アシスタントの文章はモデルが生成するため、正確な文言は異なる場合があります。確認すべき証拠は、承認カードの状態、ツール結果のフィールド、レコードのリビジョンです。キュー、チケット、レコードは合成例です。

新しい名前を付けた独立したクラウドプローブを実行します。このプローブは別のモデル呼び出しを消費しません。

python3 .labex/verify.py deployed

このプローブは、デプロイ済みの正確なバインディングと名前空間を確認し、スコープ拒否、1 回の成功した変更、同一キーの再送に対する安全性、名前付き Agent の分離を、独立した名前のリモート Agent に対して再実行します。モデル呼び出しを追加で消費せずに冪等な効果をテストします。SDK の承認ゲートを確認するのはブラウザーフローです。

承認用リソースを確認して削除する

このステップでは、実行時の動作を Cloudflare のリソース画面と結び付け、その後、この実験のリソースだけを削除します。

Cloudflare Dashboard で Workers & Pages を開き、該当する labex-c11-s06-... Worker を選択して Bindings を確認します。AI Workers AI バインディングと ApprovalAgent Durable Object バインディングが表示されます。次に Settings > Variables and Secrets を開き、SESSION_SIGNING_KEY が平文ではなく暗号化されたシークレットとして保存されていることを確認します。

AI と ApprovalAgent のバインディングを持つデプロイ済み承認 Worker

Durable Objects を開き、この Worker が所有する SQL ベースの名前空間を選択します。planning とプローブ用の名前は、この 1 つのクラス名前空間内にある別々のオブジェクトインスタンスです。

SQL ベースの ApprovalAgent 名前空間

Worker のログまたはオブザーバビリティ画面を開き、approval_change_applied を探します。受け入れられた 1 つの論理操作から、構造化されたエントリが 1 件生成されます。このエントリには Agent インスタンス、安定した操作キー、リビジョンが含まれますが、合成ケースの概要やチャットテキストは含まれません。

Cloudflare ログ内の制限された承認変更イベント 1 件

確認が終わったら、クラス削除用のマイグレーションを明示的に作成し、該当する Worker を削除します。

python3 - <<'PY'
import json
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data.pop('durable_objects', None)
data['migrations'].append({'tag': 'v2', 'deleted_classes': ['ApprovalAgent']})
Path('wrangler.cleanup.jsonc').write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force

破棄可能な Worker が存在しないことを確認します。

削除された破棄可能な承認 Worker

次に、その ApprovalAgent 名前空間が存在しないことを確認します。

削除された破棄可能な ApprovalAgent 名前空間

この VM がまだ認可されている間に、両方が存在しないことを検証します。

python3 .labex/verify.py deleted

Worker だけを削除すると、ステートフルクラスのライフサイクルが不明確なままになります。マイグレーション v2 は、この実験の名前空間、合成レコード、冪等性台帳を明示的に削除してから、Worker の削除を検証します。

この VM の認可を取り消す

このステップでは、クラウドのクリーンアップが完了したことを確認した後、一時的な VM の認可を取り消します。

npx wrangler logout
npx wrangler whoami --json || true

構造化された結果に "loggedIn": false と表示されるはずです。Wrangler が未認証を示すゼロ以外の終了コードを返す場合もあります。ログアウトは意図的に最後に行います。削除検証には有効な読み取りアクセスが必要ですが、破棄する VM には必要ないためです。

まとめ

Cloudflare AIChatAgent のレコード変更の前に、サポートされている人間の判断を組み込みました。次のことを行いました。

  • 書き込みに needsApproval を設定し、読み取りは自動のままにした。
  • approval-requested パーツを表示し、明示的な承認または拒否の応答を送信した。
  • 保留中および拒否された操作では、合成レコードが変更されないことを確認した。
  • 永続的な冪等性キーを保存し、承認済みの再試行で再度書き込まず、リビジョン 1 を返すようにした。
  • サーバー側で名前付き Agent のスコープを強制し、認可を分離した。
  • プライバシーを制限した承認イベントを確認した。
  • ログアウトする前に、対象の SQLite クラス名前空間と Worker を削除した。

これで、人間の承認が明示的かつ監査可能になり、冪等性台帳によって繰り返し配信から効果が保護されます。次の実験では方向を変え、MCP を通じて合成された読み取り専用機能を公開します。そこで、検出とトランスポートが新しい概念になります。