Validation avant effet externe
Exiger une approbation humaine avant une vraie écriture fichier, vérifier le rejet avant handler et reprendre sans dupliquer un effet acquis. La démonstration enregistre un paiement dans un registre local isolé ; elle n'envoie aucun argent et ne contacte aucun prestataire. Les montants sont des centimes entiers.
Prérequis : distribution Touba, Node.js 24 et npm. Dans un terminal macOS/Linux :
mkdir -p approval-effect-demo
cd approval-effect-demo
mkdir -p handlers vendor
npm init -y
node --input-type=module -e 'import {execFileSync} from "node:child_process"; import {copyFileSync} from "node:fs"; const v=JSON.parse(execFileSync("touba",["version","--json"],{encoding:"utf8"})); copyFileSync(v.distribution.sdk_archive,"vendor/touba-sdk.tgz");'
npm install --offline --ignore-scripts --save-exact ./vendor/touba-sdk.tgz
1. Placer la barrière avant le tool
module examples.scenario_external_approval
type PaymentRequest
reference is Text
amount_cents is Int
destination is Text
type PaymentResult
receipt is Text
tool RecordPayment
description "Append an approved demonstration payment to the local ledger. This does not transfer money."
takes request as PaymentRequest
gives PaymentResult
requires filesystem_write, secret_read
secrets LEDGER_TOKEN
strategy RunRegulatedPayment
takes request as PaymentRequest
gives PaymentResult
uses RecordPayment
do
show
with message as "Payment review requested: " ++ request.reference
human.approve
with context as request
into approval
RecordPayment
with request as request
into stored
show
with message as "Ledger receipt: " ++ stored.receipt
give stored
L'approbation libère la continuation, pas un nouveau grant. Le tool doit encore
posséder son autorité au dispatch. human.approve est du source exécutable,
pas une instruction de prompt que le modèle pourrait ignorer.
Cette recette garde l’approbation dans la stratégie d’entrée pour la lisibilité. Le checkpoint humain prend aussi en charge les validations dans les skills et stratégies appelées directement depuis la source, en conservant la pile sans rejouer les appels antérieurs. Voir la validation humaine pour le périmètre pris en charge.
2. Implémenter l'effet réel
Le handler écrit et synchronise une entrée, puis retourne un reçu appliqué.
Une écriture tentée mais non confirmée est unknown, jamais un faux succès.
Le journal runtime et retry_mode: none empêchent un redispatch automatique risqué.
import { open } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { defineTool, createToolResult, ToubaHandlerError } from "@touba/sdk";
await defineTool({
toolRef: "tool:examples.scenario_external_approval/RecordPayment",
inputSignature: [{ name: "request", type_ref: "type:examples.scenario_external_approval/PaymentRequest" }],
outputType: "type:examples.scenario_external_approval/PaymentResult",
requiredGrants: ["filesystem_write", "secret_read"], requiredSecretHandles: ["LEDGER_TOKEN"],
run: async ({ request, context }) => {
if (!context.effect) throw new ToubaHandlerError("EffectContextRequired", "A ledger write requires an effect context.");
let attempted = false;
try {
if (!context.env.LEDGER_TOKEN) throw new Error("Credential unavailable");
if (request.amount_cents <= 0) throw new Error("Amount must be positive");
const receipt = randomUUID();
const file = await open("payment-ledger.ndjson", "a", 0o600);
try {
attempted = true;
await file.writeFile(JSON.stringify({ ...request, receipt }) + "\n");
await file.sync();
} finally { await file.close(); }
return createToolResult({ receipt }, { effectReceipt: context.createEffectReceipt({ outcome: "applied" }) });
} catch {
throw new ToubaHandlerError("LedgerWriteUnconfirmed", "The ledger write did not return a confirmed result.", {
effect_receipt: context.createEffectReceipt({ outcome: attempted ? "unknown" : "not_applied" }),
});
}
},
});
import { writeFileSync } from "node:fs";
const manifest = { manifest_version: "touba-manifest-v1", bindings: {
"tool:examples.scenario_external_approval/RecordPayment": {
handler: { kind: "process", command: process.execPath, args: ["handlers/ledger.mjs"], cwd: "." },
grants: ["filesystem_write", "secret_read"], secrets: ["LEDGER_TOKEN"],
effect_contract: { kind: "external_write", retry_mode: "none" }, timeout_ms: 10000, max_payload_bytes: 65536,
} },
};
writeFileSync("touba.manifest.json", JSON.stringify(manifest, null, 2) + "\n");
manifest.bindings["tool:examples.scenario_external_approval/RecordPayment"].grants = ["secret_read"];
writeFileSync("denied.manifest.json", JSON.stringify(manifest, null, 2) + "\n");
{
"secrets_version": "touba-secrets-v1",
"secrets": {
"LEDGER_TOKEN": {
"source": "env",
"name": "LEDGER_TOKEN"
}
}
}
Le token ci-dessous est une valeur locale synthétique, pas un identifiant de paiement. Aucune vraie clé n'est requise. Le manifest complet déclare explicitement le contrat d'écriture ; un brouillon généré ne doit pas le déduire pour l'auteur.
3. Vérifier approbation, refus et reprise
Le script obtient la référence d'approbation du checkpoint réel. Ses décisions sont des entrées de test isolées, pas des approbations d'une vraie transaction.
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { ToubaEngine, ToubaEngineError } from "@touba/sdk";
const engine = new ToubaEngine({ projectRoot: ".", env: { LEDGER_TOKEN: "isolated-ledger-token" } });
const options = { source: "payment.tb", entryRef: "strategy:examples.scenario_external_approval/RunRegulatedPayment",
manifest: "touba.manifest.json", secrets: "touba.secrets.json", effectJournal: "payment.effect-journal.sqlite",
inputs: { request: { reference: "DEMO-42", amount_cents: 12500, destination: "isolated-demo-ledger" } } };
const paused = await engine.run({ ...options, humanCheckpointOut: "payment.checkpoint.json" });
assert.equal(paused.reason, "human_approval_pending");
assert.equal(existsSync("payment-ledger.ndjson"), false);
const checkpoint = JSON.parse(readFileSync("payment.checkpoint.json", "utf8"));
const approvalRef = checkpoint.approval_ref;
const rejected = await engine.run({ ...options, humanDecisions: { [approvalRef]: "rejected" } });
assert.equal(rejected.reason, "human_approval_rejected");
assert.equal(existsSync("payment-ledger.ndjson"), false);
const approved = { [approvalRef]: "approved" };
await assert.rejects(engine.run({ ...options, manifest: "denied.manifest.json", humanDecisions: approved }), error => {
assert.ok(error instanceof ToubaEngineError);
assert.equal(error.details.reason, "ToolCapabilityNotGranted");
return true;
});
assert.equal(existsSync("payment-ledger.ndjson"), false);
const resume = { ...options, inputs: undefined, humanResumeCheckpoint: "payment.checkpoint.json", humanDecisions: approved };
const completed = await engine.run(resume);
assert.equal(completed.status, "success");
assert.equal(completed.reason, "completed");
const replayed = await engine.run(resume);
assert.deepEqual(replayed.result, completed.result);
const lines = readFileSync("payment-ledger.ndjson", "utf8").trim().split("\n").map(JSON.parse);
assert.equal(lines.length, 1);
assert.equal(lines[0].receipt, completed.result.receipt);
assert.equal(lines[0].amount_cents, 12500);
assert.ok(!JSON.stringify(completed.trace).includes("isolated-ledger-token"));
console.log(JSON.stringify(completed.result));
console.log("APPROVAL_GRANTS_AND_SINGLE_ACQUIRED_WRITE_OK");
node configure.mjs
node exercise.mjs
Résultat attendu : APPROVAL_GRANTS_AND_SINGLE_ACQUIRED_WRITE_OK et une ligne
dans payment-ledger.ndjson. Attente, rejet et grant d'écriture absent ne créent
aucune entrée. Reprendre le même checkpoint avec le même journal restaure le
résultat appliqué sans second append. Un nouveau run est une nouvelle invocation.
Le checkpoint contient les données métier en JSON lisible, protégé par permissions locales mais non chiffré. Conserver checkpoint et journal privés. Ne jamais réutiliser des décisions après modification du programme ; la reprise vérifie l'identité AIR complète. Chaque itération et chaque branche possède sa propre occurrence de demande ; une décision déjà consommée ne libère pas la suivante. Voir la portée de l'approbation.
Ce cas ne promet pas un exactly-once pour n'importe quelle API de paiement. Un vrai adaptateur demande son contrat d'idempotence/réconciliation et ses contrôles d'exploitation. Voir les contrats d'effet.