Exiger une approbation pour les modifications d’enregistrements

CloudflareBeginner
Pratiquer maintenant

Introduction

Un outil validé peut malgré tout être trop prompt à agir. Lorsqu’un modèle propose de modifier un enregistrement, une personne peut devoir examiner l’action exacte avant toute écriture. Une approbation humaine avec intervention dans la boucle met cet appel d’outil en pause, affiche ses arguments dans le client et permet à une personne de l’approuver ou de le refuser.

Dans ce lab, vous allez étendre un petit Agent de support synthétique avec un outil de lecture et une mise à jour soumise à approbation :

  1. lookupSupportCase reste un outil serveur en lecture seule et s’exécute sans approbation.
  2. requestPriorityChange utilise l’option prise en charge needsApproval. Sa fonction execute ne peut donc pas s’exécuter tant que le client n’a pas envoyé une réponse d’approbation.
  3. Le client React affiche l’action en attente et appelle addToolApprovalResponse() pour approuver ou refuser l’action.
  4. Un registre d’idempotence durable enregistre la clé d’opération. Ainsi, une livraison approuvée répétée renvoie le premier résultat au lieu d’appliquer une deuxième mise à jour.
  5. Des sondes déterministes et un flux Workers AI limité prouvent les résultats en attente, refusé, approuvé et dupliqué.

L’approbation et l’autorisation répondent à des questions différentes. L’autorisation limite la file d’attente à laquelle la session signée peut accéder ; l’approbation demande si une personne accepte cette modification précise. L’idempotence traite un troisième problème : le réseau ou le client peut livrer plusieurs fois la même action approuvée. Tous les enregistrements de ce lab sont synthétiques et jetables ; aucun système réel de service d’assistance n’est connecté.

Le shell fourni et le jeton de session à durée de vie courte vous permettent de vous concentrer sur la limite d’approbation, plutôt que sur le code standard du frontend ou de l’authentification. Les allocations gratuites de Workers AI sont partagées avec les autres activités du compte. Si le compte n’a plus d’allocation disponible, arrêtez-vous au lieu d’activer une offre payante.

Avant d’accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM LabEx doit disposer de sa propre autorisation Wrangler. Les labs précédents du cours sont recommandés, mais leurs VM et leurs ressources ne sont jamais réutilisées ici.

Autoriser la VM et déclarer le Worker d’approbation

Dans cette étape, vous allez autoriser la nouvelle VM et déclarer les ressources utilisées par l’Agent soumis à approbation.

Ouvrez un terminal et entrez dans le projet préparé :

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

Autorisez cette VM :

npx wrangler login

Ouvrez le lien affiché, approuvez les autorisations Wrangler indiquées pour votre compte d’apprentissage dédié, puis revenez au terminal. Vérifiez le résultat structuré :

npx wrangler whoami --json

Recherchez "loggedIn": true, confirmez le nom du compte et copiez l’ID réel de ce compte. Enregistrez-le avec un nom de Worker jetable et unique :

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

La liaison AI fournit l’inférence du modèle sans intégrer de clé API. La liaison Durable Object attribue à chaque ApprovalAgent nommé son propre stockage SQLite pour le cas synthétique et son registre d’idempotence. Le navigateur utilisera le nom planning ; un autre nom reçoit une instance distincte et ne peut pas accéder aux données de planning. Rien n’a encore été déployé.

Définir les contrats des outils

Dans cette étape, vous allez décrire précisément les arguments acceptés par chaque outil.

Un schéma d’outil est un contrat appliqué à l’exécution. Les types TypeScript aident pendant la compilation, mais la sortie du modèle arrive à l’exécution et doit être vérifiée à nouveau. Créez 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

Le contrat de lecture accepte uniquement un nom de file valide et l’unique ticket synthétique. Le contrat de modification limite l’exercice à la priorité high et exige une operationKey stable. .strict() rejette également les champs inattendus, ce qui réduit l’ambiguïté et empêche un appelant d’introduire des instructions non prises en charge dans l’opération.

Une clé d’idempotence identifie une opération logique unique malgré les nouvelles tentatives. Le serveur enregistre le premier résultat réussi sous cette clé ; lorsqu’il reçoit à nouveau la même opération approuvée, il renvoie le résultat enregistré au lieu d’écrire deux fois. La validation n’accorde ni approbation ni accès : l’Agent vérifie toujours son nom durable et le SDK attend toujours la décision humaine.

Implémenter la limite d’approbation et l’effet idempotent

Dans cette étape, vous allez conserver des lectures automatiques, mettre l’écriture en pause avec needsApproval et rendre l’effet approuvé idempotent.

Créez 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

Le modèle n’accède jamais directement à la base de données. Il propose des arguments typés, mais needsApproval: true empêche execute de s’exécuter tant que le client n’a pas envoyé une réponse d’approbation positive. Un refus ne modifie donc pas la méthode. Le callable en lecture seule permet une inspection déterministe. Un callable de vérification distinct ne peut accéder à l’effet qu’avec une preuve HMAC dérivée du secret de signature local ; un client de navigateur ordinaire ne peut donc pas contourner la limite d’approbation humaine.

L’Agent traite chaque appel synchrone du callable sans await, de sorte qu’une deuxième livraison voit la ligne de registre créée par la première. La clé d’opération stable renvoie le résultat déjà appliqué avec duplicate: true ; elle n’incrémente pas à nouveau l’enregistrement. Seuls l’événement, l’instance de l’Agent, la clé d’opération et la révision sont consignés, et non le texte du cas.

Afficher la décision d’approbation humaine

Dans cette étape, vous allez afficher un appel d’outil en attente comme une décision explicite au lieu de l’exécuter silencieusement.

Créez la configuration TypeScript et 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

Créez 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() se connecte à un seul Agent nommé, avec son jeton à durée de vie courte. Le bouton de lecture seule et le formulaire de modification créent volontairement des tours distincts : chaque tour a un seul objectif. Vous pouvez ainsi observer l’état avant et après une décision sans mélanger un outil de lecture déjà terminé avec l’écriture en pause. useAgentChat() expose la fonction d’envoi de la réponse d’approbation. Le client Agents informe le serveur de cette décision ; la poursuite automatique du client est désactivée ici afin que le même appel d’outil approuvé ne soit pas envoyé une deuxième fois. Le résultat de l’outil et une nouvelle lecture fournissent des preuves plus solides qu’une phrase récapitulative supplémentaire générée par le modèle. isToolUIPart() distingue une action d’outil du texte ordinaire de l’assistant, tandis que getToolApproval() lit l’objet d’approbation via l’interface prise en charge du SDK. L’ID d’approbation associe la décision de la personne à cet appel d’outil précis ; le navigateur n’appelle pas directement la méthode de base de données. Les cartes JSON rendent observables le résultat de la lecture, les arguments proposés et le résultat final de l’écriture, sans exposer les identifiants du compte.

Compiler et prouver les limites localement

Dans cette étape, vous allez compiler l’application et tester l’implémentation réelle des outils sans consommer d’appel de modèle.

Générez les types exacts de l’environnement, vérifiez les types et construisez les deux bundles :

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

Wrangler déduit Cloudflare.Env à partir des liaisons réelles. Cela évite qu’une interface d’environnement écrite manuellement diverge de wrangler.jsonc.

Workers AI est une liaison distante ; l’environnement local a donc besoin de l’accès OAuth déjà enregistré par Wrangler. Transmettez-le uniquement au processus enfant, puis effacez immédiatement sa copie dans le shell :

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

N’affichez pas la valeur OAuth temporaire et ne l’enregistrez pas dans .dev.vars. La sonde indépendante utilise un Agent nommé aléatoirement, la méthode en lecture seule inspectCase() et une preuve HMAC réservée aux tests, dérivée du secret de signature local. Cette preuve permet au vérificateur de tester le même effet privé que celui utilisé par l’outil de modèle approuvé, sans publier de callable qui contournerait l’approbation. Elle prouve indépendamment la sécurité côté serveur de l’effet :

  • la priorité initiale est medium, avec la révision 0 ;
  • une lecture depuis une autre file échoue ;
  • un appel à l’effet approuvé fait passer la priorité à high, avec la révision 1 ;
  • la même clé d’opération renvoie duplicate: true et conserve la révision 1 ; et
  • un autre Agent nommé conserve son enregistrement isolé à la révision 0.

Ce test déterministe vérifie que les livraisons répétées sont sûres. Le flux du navigateur prouve séparément que la limite du SDK empêche l’effet avant l’approbation et en cas de refus.

Déployer et tester le refus, l’approbation et la répétition

Dans cette étape, vous allez déployer, puis observer la même modification proposée rester en attente, être refusée, être approuvée une fois et rester sûre lorsqu’elle est répétée.

Déployez le bundle de production et importez la clé de signature générée comme secret :

npm run deploy
npx wrangler secret bulk .dev.vars

La commande de secret transmet la valeur sans la placer dans la configuration ou dans le bundle. N’affichez pas .dev.vars.

Enregistrez l’origine exacte affichée par le déploiement, puis créez un jeton de dix minutes pour planning :

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"

Ouvrez l’URL complète dans le navigateur LabEx. Sélectionnez d’abord Check current case : le résultat en lecture seule indique la priorité medium et la révision 0. Envoyez ensuite la demande de modification préparée. Ce deuxième tour, consacré à un seul objectif, s’arrête à Approval required. Avant toute décision, sa fonction serveur execute ne s’est pas exécutée :

Modification synthétique de priorité mise en pause avant tout effet

Sélectionnez Deny. L’outil passe à l’état refusé et l’Agent ne doit pas prétendre que la mise à jour a eu lieu. Sélectionnez à nouveau Check current case : la nouvelle lecture indique toujours la priorité medium et la révision 0, ce qui prouve que l’exécution refusée n’a rien modifié. Envoyez à nouveau la demande de modification préparée pour créer une nouvelle carte d’approbation :

Opération refusée, enregistrement inchangé et nouvelle décision en attente

Sélectionnez Approve pour cette deuxième demande. Le client pris en charge renvoie l’ID d’approbation à l’Agent ; le serveur exécute l’opération une seule fois et le résultat indique la priorité high, la révision 1 et duplicate: false. Sélectionnez Check current case pour observer cette même révision de manière indépendante :

Modification synthétique approuvée et appliquée exactement une fois

Envoyez une troisième fois la même demande de modification préparée et approuvez-la. Le registre durable reconnaît raise-synthetic-priority ; le résultat indique duplicate: true. Sélectionnez encore une fois Check current case : l’enregistrement reste à la révision 1 :

Livraison approuvée répétée renvoyant le premier résultat sans nouvelle écriture

Les phrases exactes de l’assistant sont générées par le modèle et peuvent varier. L’état de la carte d’approbation, les champs du résultat de l’outil et la révision de l’enregistrement constituent les preuves utiles. La file, le ticket et l’enregistrement sont des exemples synthétiques.

Exécutez une nouvelle sonde cloud nommée indépendamment. Elle ne consomme pas d’appel de modèle supplémentaire :

python3 .labex/verify.py deployed

La sonde vérifie les liaisons et l’espace de noms exacts du déploiement, puis répète le rejet de portée, une modification réussie, la sécurité de la répétition avec une clé identique et l’isolation entre Agents nommés, sur un Agent distant nommé indépendamment. Elle teste l’effet idempotent sans consommer d’appel de modèle supplémentaire ; c’est le flux du navigateur qui prouve la limite d’approbation du SDK.

Inspecter et supprimer les ressources d’approbation

Dans cette étape, vous allez relier le comportement observé aux vues de ressources Cloudflare, puis supprimer uniquement les ressources de ce lab.

Dans le tableau de bord Cloudflare, ouvrez Workers & Pages, sélectionnez le Worker labex-c11-s06-... exact et inspectez Bindings. Vous devez voir la liaison Workers AI AI et la liaison Durable Object ApprovalAgent. Ouvrez ensuite Settings > Variables and Secrets pour confirmer que SESSION_SIGNING_KEY est stocké comme secret chiffré et non en texte brut :

Worker d’approbation déployé avec les liaisons AI et ApprovalAgent

Ouvrez Durable Objects et sélectionnez l’espace de noms adossé à SQL et détenu par ce Worker. planning et les noms utilisés par le vérificateur sont des instances d’objet distinctes au sein de cet espace de noms de classe unique :

Espace de noms ApprovalAgent adossé à SQL

Ouvrez les journaux ou la vue d’observabilité du Worker et recherchez approval_change_applied. Une opération logique acceptée produit une seule entrée structurée. Elle contient l’instance de l’Agent, la clé d’opération stable et la révision, mais pas le résumé du cas synthétique ni le texte de la conversation :

Un événement de modification approuvée limité dans les journaux Cloudflare

Après l’inspection, créez une migration explicite de suppression de classe et supprimez le Worker exact :

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

Confirmez que le Worker jetable n’existe plus :

Worker d’approbation jetable supprimé

Confirmez ensuite que son espace de noms ApprovalAgent n’existe plus :

Espace de noms ApprovalAgent jetable supprimé

Prouvez ces deux absences tant que cette VM est encore autorisée :

python3 .labex/verify.py deleted

Supprimer uniquement le Worker laisserait le cycle de vie de la classe avec état ambigu. La migration v2 supprime explicitement l’espace de noms de ce lab, l’enregistrement synthétique et le registre d’idempotence avant la vérification de la suppression du Worker.

Révoquer l’autorisation de cette VM

Dans cette étape, vous allez révoquer l’autorisation temporaire de la VM une fois le nettoyage cloud vérifié.

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

Le résultat structuré doit indiquer "loggedIn": false, ou Wrangler peut renvoyer un résultat non authentifié avec un code de sortie différent de zéro. La déconnexion est volontairement effectuée en dernier : le vérificateur de suppression a besoin d’un accès de lecture valide, tandis que la VM peut ensuite être abandonnée.

Résumé

Vous avez placé une décision humaine prise en charge devant une modification d’enregistrement d’un AIChatAgent Cloudflare. Vous avez :

  • conservé une lecture automatique tout en marquant l’écriture avec needsApproval ;
  • affiché les parties approval-requested et envoyé des réponses explicites d’approbation ou de refus ;
  • prouvé que les opérations en attente et refusées laissent l’enregistrement synthétique inchangé ;
  • enregistré une clé d’idempotence durable afin qu’une nouvelle tentative approuvée renvoie la révision 1 sans nouvelle écriture ;
  • séparé l’autorisation en imposant côté serveur la portée de l’Agent nommé ;
  • observé un événement d’approbation limité du point de vue de la confidentialité ; et
  • supprimé l’espace de noms de classe SQLite exact et le Worker avant la déconnexion.

L’approbation humaine est désormais explicite et vérifiable, tandis que le registre d’idempotence protège l’effet contre les livraisons répétées. Le prochain lab change de direction : il publie une capacité synthétique en lecture seule via MCP, où la découverte et le transport deviennent les nouveaux concepts.