Exigir aprovação para alterações em registros

CloudflareBeginner
Pratique Agora

Introdução

Uma ferramenta validada ainda pode ser excessivamente permissiva. Se um modelo propõe alterar um registro, talvez uma pessoa precise analisar a ação exata antes que qualquer gravação aconteça. Uma aprovação humana no fluxo pausa essa chamada de ferramenta, mostra os argumentos no cliente e permite que uma pessoa a aprove ou recuse.

Neste laboratório, você ampliará um pequeno Agent de suporte sintético com uma ferramenta de leitura e uma atualização protegida por aprovação:

  1. lookupSupportCase continua sendo uma ferramenta de servidor somente leitura e é executada sem aprovação.
  2. requestPriorityChange usa a opção compatível needsApproval, portanto sua função execute não pode ser executada até que o cliente envie uma resposta de aprovação.
  3. O cliente React renderiza a ação pendente e chama addToolApprovalResponse() para aprovar ou recusar.
  4. Um registro de idempotência durável armazena a chave da operação. Assim, uma entrega aprovada repetida retorna o primeiro resultado em vez de aplicar uma segunda atualização.
  5. Sondas determinísticas e um fluxo limitado do Workers AI comprovam os resultados pendente, recusado, aprovado e duplicado.

Aprovação e autorização respondem a perguntas diferentes. A autorização limita a fila que a sessão assinada pode acessar; a aprovação pergunta se uma pessoa aceita exatamente essa alteração proposta. A idempotência resolve um terceiro problema: a rede ou o cliente podem entregar a mesma ação aprovada mais de uma vez. Todos os registros deste laboratório são sintéticos e descartáveis; nenhum sistema real de suporte está conectado.

O shell fornecido e o token de sessão de curta duração mantêm o foco no limite de aprovação, sem incluir código padrão de frontend ou autenticação. As alocações gratuitas do Workers AI são compartilhadas com outras atividades da conta. Se não houver alocação restante na conta, pare em vez de ativar um plano pago.

Antes de entrar diretamente neste curso, conclua Conectar o LabEx à sua conta Cloudflare. Cada VM nova do LabEx precisa da própria autorização do Wrangler. Os laboratórios anteriores do curso são recomendados, mas suas VMs e seus recursos nunca são reutilizados aqui.

Autorizar a VM e declarar o Worker de aprovação

Nesta etapa, você autorizará a VM nova e declarará os recursos usados pelo Agent protegido por aprovação.

Abra um terminal e entre no projeto preparado:

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

Autorize esta VM:

npx wrangler login

Abra o link exibido, aprove as permissões do Wrangler documentadas para sua conta de aprendizado dedicada e volte ao terminal. Confirme o resultado estruturado:

npx wrangler whoami --json

Procure por "loggedIn": true, confirme o nome da conta e copie o ID real dessa conta. Salve-o junto com um nome exclusivo e descartável para o 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

A vinculação AI fornece inferência de modelos sem incorporar uma chave de API. A vinculação do Durable Object fornece a cada ApprovalAgent nomeado seu próprio armazenamento SQLite para o caso sintético e seu registro de idempotência. O navegador usará o nome planning; um nome diferente recebe uma instância separada e não pode consultar os dados de planning. Nada foi implantado ainda.

Definir os contratos das ferramentas

Nesta etapa, você descreverá exatamente quais argumentos cada ferramenta aceita.

Um esquema de ferramenta é um contrato em tempo de execução. Os tipos do TypeScript ajudam durante a compilação, mas a saída do modelo chega em tempo de execução e precisa ser validada novamente. Crie 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

O contrato de leitura aceita somente um nome de fila válido e o único ticket sintético. O contrato de alteração restringe o exercício à prioridade high e exige uma operationKey estável. .strict() também rejeita campos inesperados, reduzindo a ambiguidade e impedindo que um chamador inclua instruções não compatíveis na operação.

Uma chave de idempotência identifica uma operação lógica entre novas tentativas. O servidor armazenará o primeiro resultado bem-sucedido com essa chave; ao receber novamente a mesma operação aprovada, retornará o resultado armazenado em vez de gravar duas vezes. A validação não concede aprovação nem acesso — o Agent ainda verifica seu nome durável, e o SDK ainda aguarda a decisão humana.

Implementar o bloqueio de aprovação e o efeito idempotente

Nesta etapa, você manterá as leituras automáticas, pausará a gravação com needsApproval e tornará o efeito aprovado idempotente.

Crie 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

O modelo nunca recebe acesso direto ao banco de dados. Ele propõe argumentos tipados, mas needsApproval: true impede que execute seja executada até que o cliente envie uma resposta de aprovação positiva. Portanto, uma recusa deixa o método intacto. O callable somente leitura permite uma inspeção determinística. Um callable de verificação separado só pode acessar o efeito com uma prova HMAC derivada do segredo de assinatura local; assim, um cliente comum do navegador não consegue contornar o bloqueio humano.

O Agent trata cada invocação síncrona de callable sem um await, portanto uma segunda entrega encontra a linha do registro criada pela primeira. A chave de operação estável retorna o resultado já aplicado com duplicate: true; ela não incrementa o registro novamente. Somente o evento, a instância do Agent, a chave da operação e a revisão são registrados — o texto do caso não é.

Renderizar a decisão de aprovação humana

Nesta etapa, você renderizará uma chamada de ferramenta pendente como uma decisão explícita, em vez de executá-la silenciosamente.

Crie a configuração do TypeScript e do 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

Crie 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() conecta-se exatamente a um Agent nomeado usando seu token de curta duração. O botão somente leitura e o formulário de alteração criam deliberadamente turnos separados: cada turno tem uma finalidade, permitindo que o aluno observe o estado antes e depois de uma decisão sem misturar uma ferramenta de leitura já concluída à gravação pausada. useAgentChat() disponibiliza o auxiliar de resposta à aprovação. O cliente Agents notifica o servidor sobre essa decisão; a continuação automática do cliente está desativada aqui para que a mesma chamada de ferramenta aprovada não seja enviada uma segunda vez. O resultado da ferramenta e uma nova leitura fornecem evidências mais fortes do que uma frase adicional gerada pelo modelo. isToolUIPart() distingue uma ação de ferramenta do texto comum do assistente, enquanto getToolApproval() lê o objeto de aprovação pela interface compatível do SDK. O ID de aprovação vincula a decisão da pessoa a essa chamada de ferramenta específica; o navegador não chama diretamente o método do banco de dados. Os cartões JSON tornam observáveis o resultado da consulta, os argumentos propostos e o resultado final da gravação, sem expor credenciais da conta.

Compilar e comprovar os limites localmente

Nesta etapa, você compilará a aplicação e executará a implementação real das ferramentas sem consumir uma chamada ao modelo.

Gere os tipos exatos do ambiente, verifique os tipos e compile os dois bundles:

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

O Wrangler deriva Cloudflare.Env das vinculações reais. Isso impede que uma interface de ambiente escrita manualmente fique diferente de wrangler.jsonc.

O Workers AI é uma vinculação remota, portanto o runtime local precisa do acesso OAuth já armazenado pelo Wrangler. Passe esse valor somente ao processo filho e limpe imediatamente a cópia no 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ão exiba o valor OAuth temporário nem o salve em .dev.vars. A sonda independente usa um Agent nomeado aleatoriamente, o método somente leitura inspectCase() e uma prova HMAC exclusiva para teste, derivada do segredo de assinatura local. Essa prova permite ao verificador exercitar o mesmo efeito privado usado pela ferramenta de modelo aprovada, sem publicar um callable que contorne a aprovação. Ela comprova a segurança do efeito no servidor independentemente do comportamento do modelo:

  • a prioridade inicial é medium, na revisão 0;
  • uma leitura de outra fila falha;
  • uma chamada ao efeito aprovado muda a prioridade para high, na revisão 1;
  • a mesma chave de operação retorna duplicate: true e continua na revisão 1; e
  • outro Agent nomeado mantém seu registro isolado na revisão 0.

Esse teste determinístico responde se uma entrega repetida é segura. O fluxo do navegador comprova separadamente que o bloqueio compatível do SDK impede o efeito antes da aprovação e em caso de recusa.

Implantar e testar recusa, aprovação e repetição

Nesta etapa, você fará a implantação e observará a mesma alteração proposta permanecer pendente, ser recusada, ser aprovada uma vez e continuar segura quando repetida.

Implante o bundle de produção e envie a chave de assinatura gerada como um secret:

npm run deploy
npx wrangler secret bulk .dev.vars

O comando de secret envia o valor sem colocá-lo na configuração nem no bundle. Não exiba .dev.vars.

Salve a origem exata exibida pela implantação e crie um token de dez minutos para 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"

Abra a URL completa no navegador do LabEx. Primeiro, selecione Check current case: o resultado somente leitura informa prioridade medium na revisão 0. Em seguida, envie a solicitação de alteração preparada. Esse segundo turno, com uma única finalidade, para em Approval required. Antes de uma decisão, sua função execute no servidor ainda não foi executada:

Uma alteração sintética de prioridade pausada antes de qualquer efeito

Selecione Deny. A ferramenta passa a ser recusada, e o Agent não deve afirmar que a atualização ocorreu. Selecione Check current case novamente: a nova leitura ainda informa prioridade medium na revisão 0, comprovando que a execução recusada não alterou nada. Envie novamente a solicitação de alteração preparada para criar um novo cartão de aprovação:

Uma operação recusada seguida de um registro inalterado e de uma nova decisão pendente

Selecione Approve nessa segunda solicitação. O cliente compatível envia o ID de aprovação de volta ao Agent, o servidor executa uma única vez e o resultado informa prioridade high, revisão 1 e duplicate: false. Selecione Check current case para observar a mesma revisão de forma independente:

A alteração sintética aprovada aplicada exatamente uma vez

Envie a mesma solicitação de alteração preparada uma terceira vez e aprove-a. O registro durável reconhece raise-synthetic-priority; o resultado informa duplicate: true. Selecione Check current case mais uma vez; o registro permanece na revisão 1:

Uma entrega aprovada repetida que retorna o primeiro resultado sem outra gravação

As frases exatas do assistente são geradas pelo modelo e podem variar. O estado do cartão de aprovação, os campos do resultado da ferramenta e a revisão do registro são as evidências úteis. A fila, o ticket e o registro são exemplos sintéticos.

Execute uma sonda de nuvem nova e independente. Ela não consome outra chamada ao modelo:

python3 .labex/verify.py deployed

A sonda verifica as vinculações e o namespace exatos implantados; depois repete a rejeição de escopo, uma alteração bem-sucedida, a segurança da repetição com a mesma chave e o isolamento de Agents nomeados contra um Agent remoto nomeado de forma independente. Ela testa o efeito idempotente sem consumir outra chamada ao modelo; é o fluxo do navegador que comprova o bloqueio de aprovação do SDK.

Inspecionar e remover os recursos de aprovação

Nesta etapa, você relacionará o comportamento do runtime às visualizações de recursos da Cloudflare e depois excluirá somente os recursos deste laboratório.

No Cloudflare Dashboard, abra Workers & Pages, selecione o Worker exato labex-c11-s06-... e inspecione Bindings. Você deverá ver a vinculação AI do Workers AI e a vinculação do Durable Object ApprovalAgent. Em seguida, abra Settings > Variables and Secrets para confirmar que SESSION_SIGNING_KEY está armazenado como um secret criptografado, e não como texto simples:

O Worker de aprovação implantado com as vinculações AI e ApprovalAgent

Abra Durable Objects e selecione o namespace baseado em SQL pertencente a este Worker. Os nomes planning e os nomes usados pelo verificador são instâncias de objetos separadas dentro de um único namespace de classe:

O namespace ApprovalAgent baseado em SQL

Abra os logs do Worker ou a visualização de observabilidade e procure approval_change_applied. Uma operação lógica aceita produz uma única entrada estruturada. Ela contém a instância do Agent, a chave de operação estável e a revisão, mas não o resumo do caso sintético nem o texto do chat:

Um evento limitado de alteração aprovada nos logs da Cloudflare

Depois da inspeção, crie uma migração explícita para excluir a classe e remova o Worker exato:

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

Confirme que o Worker descartável não está mais presente:

O Worker de aprovação descartável removido

Em seguida, confirme que o namespace ApprovalAgent correspondente não está mais presente:

O namespace ApprovalAgent descartável removido

Comprove ambas as ausências enquanto esta VM ainda estiver autorizada:

python3 .labex/verify.py deleted

Excluir apenas o Worker deixaria ambíguo o ciclo de vida da classe com estado. A migração v2 remove explicitamente o namespace deste laboratório, o registro sintético e o registro de idempotência antes que a exclusão do Worker seja verificada.

Revogar a autorização desta VM

Nesta etapa, você revogará a autorização temporária da VM depois de comprovar a limpeza na nuvem.

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

O resultado estruturado deverá informar "loggedIn": false, ou o Wrangler poderá retornar um resultado não autenticado com código diferente de zero. O logout é feito intencionalmente por último: o verificador de exclusão precisa de acesso de leitura válido, enquanto a VM descartada não precisa mais dele.

Resumo

Você colocou uma decisão humana compatível na frente de uma alteração de registro de um AIChatAgent da Cloudflare. Você:

  • manteve a leitura automática e marcou a gravação com needsApproval;
  • renderizou partes approval-requested e enviou respostas explícitas de aprovação ou recusa;
  • comprovou que operações pendentes e recusadas deixam o registro sintético inalterado;
  • armazenou uma chave de idempotência durável para que uma nova tentativa aprovada retorne a revisão 1 em vez de gravar novamente;
  • manteve a autorização separada, impondo no servidor o escopo do Agent nomeado;
  • observou um evento de aprovação com privacidade limitada; e
  • excluiu o namespace exato da classe SQLite e o Worker antes de sair da conta.

A aprovação humana agora é explícita e auditável, enquanto o registro de idempotência protege o efeito contra entregas repetidas. O próximo laboratório muda de direção: ele publica uma capacidade sintética somente leitura por meio do MCP, em que descoberta e transporte passam a ser os novos conceitos.