Skip to content
Back to Blog
Trust, audit, governance
May 6, 2026
·by Piyush·5 min read

Approval Gates in Code: The Destructive-Mode Handshake

ContextOS
Harness Engineering
Approval Gates
Frozen Evidence
Signing
Share:XHN

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  →  executes

Four typed envelopes, one per arrow:

harness/gates/types.ts
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:

harness/gates/propose.ts
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:

harness/gates/freeze.ts
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:

harness/gates/request.ts
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:

harness/gates/sign.ts
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:

harness/gates/redeem.ts
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:

runtime/refund-with-gate.ts
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.

Found this useful? Share it.

Share:XHN
Analytics consent

We use Google Analytics to understand site usage. You can opt in or decline.