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:
lookupSupportCasecontinua sendo uma ferramenta de servidor somente leitura e é executada sem aprovação.requestPriorityChangeusa a opção compatívelneedsApproval, portanto sua funçãoexecutenão pode ser executada até que o cliente envie uma resposta de aprovação.- O cliente React renderiza a ação pendente e chama
addToolApprovalResponse()para aprovar ou recusar. - 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.
- 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ão0; - uma leitura de outra fila falha;
- uma chamada ao efeito aprovado muda a prioridade para
high, na revisão1; - a mesma chave de operação retorna
duplicate: truee continua na revisão1; 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:

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:

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:

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:

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:

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:

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:

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:

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

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-requestede 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
1em 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.



