Most teams I have audited wire an approval gate as a button and a webhook. The button calls the webhook with the call’s args; the webhook either runs the tool or doesn’t. That works until the day someone approves an action whose evidence had silently changed in the 90 seconds between proposal and click.
We had that day. The evidence the approver saw said the order had not shipped; by the time they clicked approve, the warehouse had updated and the order was already on a truck. The refund went through anyway because the gate was a button, not a contract.
An approval gate is a contract. It is a typed handshake between three actors: the agent’s proposed call, the human’s signature over a frozen evidence snapshot, and the gateway’s redemption check. This post is each artifact in code and the worked refund through them. The full Trust-plane spec lives in Governance; this post is the operator’s compressed version.
The four artifacts
agent human gateway
proposes → freezes evidence → signs → redeems → executesFour typed envelopes, one per arrow:
export type ProposedDestructiveCall = {
proposal_id: string // pdc_a17
trace_id: string
run_id: string
adapter_id: string
capability_id: string
args: Record<string, unknown>
evidence_refs: string[] // refs the call depends on
approval_mode: "destructive"
proposed_by: string // agent_workload_id
proposed_at: string
}
export type ApprovalRequest = {
request_id: string // areq_a18
proposal_id: string
gate_id: string // GATE_HIGH_VALUE
evidence_snapshot_hash: string // sha256 over canonical payloads
evidence_summary: Array<{
ref: string
payload_excerpt: string // human-readable
classification: string
}>
reviewer_recommendations: Array<{
reviewer_id: string // compliance.v1, reliability.v1
status: "pass" | "warn" | "fail"
finding_count: number
}>
expires_at: string // hard cutoff (typically 15 min)
rendered_at: string
}
export type ApprovalSignature = {
signature_id: string // sig_a19
request_id: string
approver: string // user_id of the signer
evidence_snapshot_hash: string // MUST match request
approver_role: string // "finance_lead" | "ops_manager"
decision: "approve" | "deny"
reason_class?: string // typed reason if deny
reason_text?: string
signature: string // ed25519 over the request hash
signed_at: string
}
export type RedeemableGate = {
proposal_id: string
signature: ApprovalSignature
redemption_window: { from: string; to: string }
}Five things this shape enforces that a button-and-webhook does not.
The proposal carries the trace_id, so the gate event lands in the same audit chain as everything else. Gates without a trace_id are a black hole; replay cannot reproduce them.
The request carries the evidence snapshot hash, computed at request time. The hash is what the approver implicitly signs over — not the args, not a free-text note. Anything that changes the hash invalidates the signature, and the approver knows that the moment they sign.
The signature carries the same hash. Drift between request-time and sign-time evidence shows up as a hash mismatch. If the approver’s UI re-fetched evidence between render and click, the request must be re-issued; the prior hash is no longer signable.
The redemption window is bounded. Signed approvals do not live forever. Fifteen minutes is typical for high-value refunds; tighter for irreversible actions; longer for asynchronous human approval (legal review, etc.). The gateway refuses redemption past to.
Reasons for denial are typed. A denied gate produces a reason_class from a fixed enum, not a paragraph. The Improvement Loop reads denial classes the same way it reads operator overrides.
Step 1 — propose
The Tool Gateway emits the proposal when a destructive call comes through:
import type { ToolCall } from "@/tools/types"
import type { ProposedDestructiveCall } from "./types"
import { writeProposal } from "@/store"
export async function proposeGate(call: ToolCall): Promise<ProposedDestructiveCall> {
if (call.approval_mode !== "destructive") {
throw new Error(`proposeGate refuses non-destructive call ${call.call_id}`)
}
const proposal: ProposedDestructiveCall = {
proposal_id: `pdc_${shortid()}`,
trace_id: call.trace_id,
run_id: call.run_id,
adapter_id: call.adapter_id,
capability_id: call.capability_id,
args: call.args,
evidence_refs: call.evidence_refs,
approval_mode: "destructive",
proposed_by: call.proposed_by ?? "unknown",
proposed_at: new Date().toISOString(),
}
await writeProposal(proposal)
return proposal
}The proposal is immutable. If the agent revises the call, that becomes a new proposal — never an edit.
Step 2 — freeze evidence
The frozen-evidence hash is the contract the signature later attests to. The function reads each evidence ref, normalizes the payload, and hashes the canonicalized list:
import { createHash } from "node:crypto"
import { resolveEvidence } from "@/store"
const stable = (o: unknown) => JSON.stringify(o, Object.keys(o as object).sort())
export async function freezeEvidence(refs: string[]): Promise<string> {
const resolved = await Promise.all(refs.map((r) => resolveEvidence(r)))
// refs are pinned to a snapshot — same input, same output, every time
const canonical = stable(resolved.map((e) => ({ id: e.id, payload: e.payload })))
return "sha256:" + createHash("sha256").update(canonical).digest("hex")
}The function is pure. It reads pinned snapshot data; it does not call live APIs. Two invocations five seconds apart on the same refs return the same hash. That is what makes the redemption check meaningful.
Step 3 — render the approval request
The runtime composes the request the human will sign over:
import type { ProposedDestructiveCall, ApprovalRequest } from "./types"
import { freezeEvidence } from "./freeze"
import { renderEvidenceSummary, runReviewerRecommendations } from "@/runtime"
import { writeApprovalRequest } from "@/store"
const GATE_TTL_MS = 15 * 60 * 1000
export async function buildApprovalRequest(p: ProposedDestructiveCall): Promise<ApprovalRequest> {
const hash = await freezeEvidence(p.evidence_refs)
const summary = await renderEvidenceSummary(p.evidence_refs)
const recommendations = await runReviewerRecommendations(p)
const now = Date.now()
const req: ApprovalRequest = {
request_id: `areq_${shortid()}`,
proposal_id: p.proposal_id,
gate_id: gateIdFor(p), // GATE_HIGH_VALUE for refunds > 10000
evidence_snapshot_hash: hash,
evidence_summary: summary, // payload_excerpt is what the operator reads
reviewer_recommendations: recommendations, // compliance, reliability, etc.
expires_at: new Date(now + GATE_TTL_MS).toISOString(),
rendered_at: new Date(now).toISOString(),
}
await writeApprovalRequest(req)
return req
}
function gateIdFor(p: ProposedDestructiveCall): string {
if (p.adapter_id === "adp_payments" && p.capability_id === "issue_refund") {
const amount = (p.args.amount_inr as number | undefined) ?? 0
return amount >= 10000 ? "GATE_HIGH_VALUE" : "GATE_LOW_VALUE"
}
return "GATE_GENERIC"
}The reviewer recommendations are the part operators care about most. Compliance and Reliability reviewer agents from the reviewer-agent series emit typed verdicts that show up here as a row each. Operators read them before the call, not after.
Step 4 — sign
The operator UI calls the sign function with the operator’s approve or deny:
import type { ApprovalRequest, ApprovalSignature } from "./types"
import { writeSignature, signWithApproverKey } from "@/identity"
export async function signApproval(args: {
request: ApprovalRequest
approver: string
approver_role: string
decision: "approve" | "deny"
reason_class?: string
reason_text?: string
}): Promise<ApprovalSignature> {
const { request, approver, approver_role, decision } = args
if (Date.now() > Date.parse(request.expires_at)) {
throw new Error(`request ${request.request_id} expired; cannot sign`)
}
const signature: ApprovalSignature = {
signature_id: `sig_${shortid()}`,
request_id: request.request_id,
approver,
evidence_snapshot_hash: request.evidence_snapshot_hash,
approver_role,
decision,
reason_class: args.reason_class,
reason_text: args.reason_text,
signature: await signWithApproverKey(approver, requestHashOf(request)),
signed_at: new Date().toISOString(),
}
await writeSignature(signature)
return signature
}The signature is over the request’s hash, not the request itself. The hash includes the evidence_snapshot_hash, which makes evidence drift cryptographically detectable: if anyone changes the snapshot between render and sign, the request hash changes, the signature no longer verifies, and the redemption refuses.
signWithApproverKey uses an ed25519 key bound to the approver identity. Keys rotate on a quarterly cadence; the replay harness keeps revoked keys queryable so historical signatures still verify.
Step 5 — redeem
The runtime hands the signed gate back to the gateway. The gateway’s redemption check is the last line of defense before execution:
import type { RedeemableGate, ProposedDestructiveCall } from "./types"
import { verifySignature, loadProposal, loadRequest } from "@/store"
import { freezeEvidence } from "./freeze"
export type RedemptionResult =
| { ok: true; reason: "approved" }
| { ok: false; kind:
| "denied" | "expired" | "evidence_drift"
| "signature_invalid" | "not_authorized"
reason: string }
export async function redeemGate(
call: { proposal_id: string },
signedGate: RedeemableGate,
): Promise<RedemptionResult> {
const proposal = await loadProposal(call.proposal_id)
const request = await loadRequest(signedGate.signature.request_id)
if (!proposal || !request) return { ok: false, kind: "expired", reason: "proposal/request not found" }
if (signedGate.signature.decision === "deny") {
return { ok: false, kind: "denied",
reason: `denied by ${signedGate.signature.approver}: ${signedGate.signature.reason_class ?? "unspecified"}` }
}
// 1. window — gate must still be valid
const now = new Date().toISOString()
if (now < signedGate.redemption_window.from || now > signedGate.redemption_window.to) {
return { ok: false, kind: "expired", reason: `outside redemption window` }
}
// 2. signature must verify against the approver's effective key at sign-time
const sigOk = await verifySignature(signedGate.signature, requestHashOf(request))
if (!sigOk) return { ok: false, kind: "signature_invalid", reason: "signature did not verify" }
// 3. authorization — approver_role must be allowed for this gate_id
if (!isAuthorizedForGate(signedGate.signature.approver_role, request.gate_id)) {
return { ok: false, kind: "not_authorized",
reason: `role ${signedGate.signature.approver_role} cannot sign ${request.gate_id}` }
}
// 4. THE critical check — re-freeze evidence right now and compare
const liveHash = await freezeEvidence(proposal.evidence_refs)
if (liveHash !== signedGate.signature.evidence_snapshot_hash) {
return { ok: false, kind: "evidence_drift",
reason: `signed hash ${signedGate.signature.evidence_snapshot_hash} != live hash ${liveHash}` }
}
return { ok: true, reason: "approved" }
}The four checks earn their keep:
Decision. A deny is the simplest outcome: refuse with the typed reason class. The denial event is what the Improvement Loop reads to detect over-caution patterns.
Window. Approvals beyond redemption_window.to refuse with expired. Time-bounded approvals catch the “I approved this last week, why is it running now?” class of bug that haunts manual workflows.
Signature. Cryptographic verification against the request hash. Tampering with any field in the request between sign and redeem invalidates the signature.
Evidence drift. The check that earned the post. The gateway re-freezes evidence at redemption time and compares. The order shipped between sign and redeem? evidence_drift, refuse, replan. The customer cancelled? evidence_drift, refuse, replan. Anything the approver implicitly relied on must still be true.
A worked refund
Putting it together for the ₹24,500 refund from End-to-End Refund:
const proposal = await proposeGate({
/* destructive ToolCall built by the gateway resolver */
call_id: "tc_121",
trace_id: ctx.trace_id,
run_id: ctx.run_id,
adapter_id: "adp_payments",
capability_id: "issue_refund",
approval_mode: "destructive",
args: { id: "pay_8861", amount_inr: 24500 },
evidence_refs: [
"kg:order:ord_881#snapshot_kg_2026_05_06_T0930",
"kg:refund_window:rw_881#snapshot_kg_2026_05_06_T0930",
],
initiated_at: new Date().toISOString(),
})
const req = await buildApprovalRequest(proposal)
// emits to operator UI; renders evidence_summary + reviewer_recommendations
// human reviews and clicks approve in the UI
const sig = await signApproval({
request: req,
approver: "user_finance_lead_77",
approver_role: "finance_lead",
decision: "approve",
})
const redemption = await redeemGate({ proposal_id: proposal.proposal_id }, {
proposal_id: proposal.proposal_id,
signature: sig,
redemption_window: { from: req.rendered_at, to: req.expires_at },
})
if (redemption.ok) {
await dispatch(/* the original ToolCall */ )
} else {
await emitDecisionEvent(ctx, { kind: "gate_refused", reason: redemption.kind, detail: redemption.reason })
return finalize(/* refused-by-gate path */)
}The gate’s verdict — approved, denied, expired, drifted, invalid — lands in the DecisionRecord either way. Replay reproduces every outcome.
What this discipline buys you
Three things on day one of running typed gates instead of buttons.
Drift cannot escape audit. The hash compare between sign-time and redeem-time evidence catches the class of bug that haunts every manual approval workflow. There is no version of “the approver thought X was true and it actually wasn’t” that survives this gate.
Denials are typed. A reason_class enum means denial patterns are queryable: “show me every evidence_was_stale denial last quarter” is a join, not a folder of email threads.
Signing keys cycle correctly. Approvals that signed against a key effective in March still verify in October because the signing-key registry retains revoked keys for replay. The replay harness needs this; so does the audit story.
Three files: propose.ts, request.ts plus freeze.ts, sign.ts, redeem.ts. Roughly 160 lines plus the typed envelopes. Wire them between the gateway resolver and dispatch the day you ship your first destructive capability; every later destructive capability is one new line in gateIdFor().
The button is the easy part. The contract is what makes the button safe.