Tutorial 2: Bind a destructive tool through the Tool Gateway
Onboard a destructive capability with approval-mode binding, an approval gate, and a frozen evidence snapshot.
In Tutorial 1 we shipped a read_only pack. Here we add a destructive capability — adp_payments.issue_refund — and route it through the Tool Gateway with an approval gate that captures a frozen evidence snapshot before execution.
What we are building
view source
sequenceDiagram participant Pl as Planner participant Cr as Critic participant TG as Tool Gateway participant Ap as Approver participant Ad as Adapter Pl->>Cr: Plan with s3 (destructive) Cr->>TG: propose s3 with frozen evidence TG->>Ap: GATE_FINANCE_APPROVAL pending Ap-->>TG: approve(snapshot_hash) TG->>Ad: execute s3 Ad-->>TG: ToolResultEnvelope(completed) TG-->>Cr: audit + evidence_refs
The adapter declares the highest mode the capability can produce. Policy may select a lower effective mode for a specific bounded request when the declared maximum allows it, but it cannot exceed that maximum.
{
"adapter_id": "adp_payments",
"type": "OPENAPI",
"endpoint_ref": "internal://payments",
"capabilities": ["issue_refund"],
"approval_mode": "destructive"
}Capability classification: act (mutates external state). Distinct from verify / observe even when both can carry network mode.
Permissions bind a role to a capability under explicit arg_constraints. idempotency_key is required on every write-class call.
{
"permission_id": "p_refund",
"adapter_id": "adp_payments",
"capability": "issue_refund",
"allow": true,
"arg_constraints": {
"amount_inr": { "min": 1, "max": 50000 },
"currency": { "enum": ["INR"] },
"idempotency_key": { "required": true, "pattern": "^ik_[a-z0-9]{16}$" }
}
}The gate is named in the policy bundle and triggered by a JsonLogic when predicate evaluated against the Run Context.
{
"approval_gates": [
{
"gate_id": "GATE_FINANCE_APPROVAL",
"when": { ">": [{ "var": "request.context.refund_amount" }, 3000] },
"required_approver_role": "finance_lead",
"ttl_seconds": 7200
}
]
}A policy rule fires the gate and binds the capability call to a decision_key from the Decision Catalog.
{
"rule_id": "R_HIGH_VALUE_REQUIRES_APPROVAL",
"applies_to": { "intent": "support.refund" },
"if": {
"and": [
{ "==": [{ "var": "user.role" }, "support_agent"] },
{ ">": [{ "var": "request.context.refund_amount" }, 3000] }
]
},
"then": {
"allow": true,
"approval_mode": "destructive",
"requires_approval_gate": "GATE_FINANCE_APPROVAL",
"arg_constraints": { "refund_amount": { "max": 3000, "unless_approved": true } }
},
"decision_binding": "decision.support.refund.execute"
}The Tool Gateway routes network / delegated / destructive calls through three steps:
- Propose — the Critic emits a frozen evidence snapshot (sha256 of
evidence_refs). - Approve — the named approver responds with their assertion (WebAuthn, etc.).
- Execute — only if
proposed_action_hash == executed_action_hash. Divergence aborts.
The ToolResultEnvelope carries the audit:
{
"tool_call_id": "tc_118",
"run_id": "run_refund_2026_05_18",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"capability_id": "adp_payments.issue_refund",
"status": "completed",
"approval_mode_effective": "destructive",
"policy_decision_id": "pol_9901",
"metadata": {
"approver": "user_finance_lead_77",
"approval_evidence_snapshot_hash": "sha256:b2a1..."
},
"mutations": [{ "mutation_ref": "tool:adp_payments.issue_refund:tc_118" }],
"citations": ["refund:rf_118"]
}What’s next
Tutorial 3: Wire a typed Decision Catalog entry - author a DecisionSpec with required_evidence so the Critic emits a typed DecisionRecord that audit can replay.