Skip to content

Instantly share code, notes, and snippets.

@renezander030
Last active August 2, 2026 09:07
Show Gist options
  • Select an option

  • Save renezander030/9069db775e494ffd2cdd5a09adf83add to your computer and use it in GitHub Desktop.

Select an option

Save renezander030/9069db775e494ffd2cdd5a09adf83add to your computer and use it in GitHub Desktop.
Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs

Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs

Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs

Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs

Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs

Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs

Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs

Production AI Automation Notes #1: Agent Approval Gates

Updated 2026-04-28 — JSON schemas, human review, deterministic dispatch, and audit logs for AI agents that touch real systems.

The most common shape of an "AI agent" demo is: model decides → model calls API → side effect happens. That works in a sandbox. It does not work the moment the agent is allowed to send an email to a customer, update a CRM record, or trigger an n8n workflow that hits production.

The pattern that does work is older than agents and is borrowed from financial systems: draft → validate → approve → dispatch → audit. Five steps, five contracts, no exceptions.

This is part 1 of Production AI Automation Notes — a series on shipping AI agents outside demos. The reference implementation for this post is in agent-approval-gate (MIT, JSON Schema + n8n + email examples).

The shape of the gate

   AI Agent
      │
      ▼  ProposedAction (serialized)
   Schema validation
      │
      ▼  validated
   Approval queue (human or policy)
      │
      ▼  ApprovalRecord
   Deterministic dispatcher
      │
      ▼  side effect
   Append-only audit log

The agent never reaches the side-effect surface. The dispatcher does. The two communicate only through validated documents.

Five contracts

1. ProposedAction — the agent drafts, never executes

A ProposedAction is a JSON document. It describes what the agent wants to do, in a shape the dispatcher can route on. The agent does not have credentials for the email API, the CRM API, or the n8n trigger URL. It cannot bypass the gate even if it tries.

{
  "proposal_id": "prop_2026-04-28_b3f1a2c4",
  "agent": { "name": "support-triage", "version": "1.4.2" },
  "action_type": "email.send",
  "payload": {
    "to": ["customer@example.com"],
    "subject": "Re: cannot export invoices",
    "body_text": "Thanks for the report..."
  },
  "rationale": "Customer's export issue maps to the rate-limit incident from 2026-04-22.",
  "tenant": "acme-corp",
  "expires_at": "2026-04-28T17:14:23Z",
  "risk": "low"
}

Two reasons this matters:

  • Schema enforcement is possible. A serialized contract can be validated. A function call cannot. Agents that call APIs directly drift their parameter shape across runs and you only notice when production breaks.
  • Bypass prevention. If only the dispatcher has the API key, the approval step is the path. Not the recommended path — the only path.

2. Schema validation at the boundary

Every action_type has a schema. Reject malformed drafts the moment they arrive at the queue, not later.

import Ajv from "ajv";
import addFormats from "ajv-formats";
import schema from "./proposed-action.schema.json";

const ajv = addFormats(new Ajv({ strict: true }));
const validate = ajv.compile(schema);

function acceptProposal(proposal) {
  if (!validate(proposal)) {
    throw new Error("Invalid ProposedAction: " + JSON.stringify(validate.errors));
  }
  return proposal;
}

The dispatcher gets to assume well-formed input. That assumption is paid for once, at the boundary.

3. ApprovalRecord — the decision is its own document

Approvals are not a flag on the proposal. They are a separate, append-only record:

{
  "proposal_id": "prop_2026-04-28_b3f1a2c4",
  "decision": "approved",
  "decided_by": { "kind": "human", "identifier": "rene@acme-corp" },
  "decided_at": "2026-04-28T09:18:51Z",
  "channel": "telegram",
  "comment": "Confirmed against postmortem-2026-04-22, send."
}

This shape supports:

  • multi-approver flows (each approver writes their own record),
  • policy auto-approval (decided_by.kind = "policy", identifier = "low-risk-internal-tenant"),
  • per-channel trust differences (signed Slack buttons vs. Telegram replies vs. authenticated web UI).

If you store the decision as proposal.approved = true, you have lost the audit case where someone approved, the proposal expired, and the dispatcher rejected it. Keep the events separate.

4. Dispatcher — plain code, no model

The dispatcher reads (ProposedAction, ApprovalRecord), re-validates both, and executes. It is intentionally boring code. No prompt, no model call, no chain-of-thought.

async function dispatch(proposal, approval) {
  if (approval.proposal_id !== proposal.proposal_id) throw new Error("ID mismatch");
  if (approval.decision !== "approved" && approval.decision !== "auto_approved") return;
  if (proposal.expires_at && new Date(proposal.expires_at) < new Date()) return;

  switch (proposal.action_type) {
    case "email.send":           return sendEmail(proposal.payload);
    case "crm.update_record":    return updateCrm(proposal.payload);
    case "n8n.trigger_workflow": return triggerN8n(proposal.payload);
    default: throw new Error("Unknown action_type");
  }
}

If you find yourself wanting the model to "decide how to dispatch," the action_type enum is too coarse. Split it into more specific types instead of asking the model to branch.

5. Audit log — three rows minimum

For every proposal that leaves the agent, the log grows by at least three append-only rows:

event refs
proposed proposal_id, full payload, agent identity
decided proposal_id, channel, decided_by, decision
dispatched proposal_id, side-effect outcome (success/failure, external IDs)

If the proposal is rejected or expires, the third row is not_dispatched with the reason. The log should be queryable by proposal_id so the full lifecycle is one fetch.

Why "human-in-the-loop" is not enough

The phrase usually means the human reads what the model said and clicks OK. That click happens, but three failures slip through anyway:

  • Schema drift. The model emits a slightly different shape next week. The approver doesn't notice. The dispatcher silently does the wrong thing.
  • Dispatch coupling. The agent itself calls the API, so an approval step exists but the model can also bypass it.
  • No audit. You cannot answer "why did this email go out" three weeks later.

The five contracts close all three.

Channel choice

Channel Trust Latency Best for
Web UI (authenticated) High Low Frequent approvers
Slack DM (signed buttons) High Low Existing Slack-first teams
Telegram bot Medium Very low Solo operators, fast on mobile
Email Low High One-off / out-of-band only
n8n form Medium Low When the rest of the pipeline is in n8n

Signed ApprovalRecord is mandatory for high-risk actions on Telegram and email — both can be spoofed. Authenticated web UIs and signed Slack buttons provide the signature implicitly.

What this pattern does NOT do

  • It does not prevent prompt injection. That is a different layer (input sanitization, system-prompt isolation). The gate stops a successful injection from causing real-world damage; it does not stop the injection from happening.
  • It does not replace rate limits. A pipeline drafting 10k proposals/minute will overwhelm the approver. Cap drafts at the source.
  • It does not handle compensation. If a dispatched action turns out wrong, the gate does not roll it back. Plan rollback per action_type.

When you don't need this

  • Read-only agents (search, summarize, analyze).
  • Internal-only agents whose entire surface is a sandbox.
  • Single-user prototypes where you are the agent's user and the side-effect target.

The moment the agent's output reaches a customer, an external API, or a multi-tenant database, you need the gate. There is no in-between.

Reference implementation

agent-approval-gate ships:

  • schemas/proposed-action.schema.json — the agent's draft contract (JSON Schema 2020-12)
  • schemas/approval-record.schema.json — the approval decision contract
  • examples/email-reply-approval.json — a realistic support-triage proposal
  • examples/n8n-approval-workflow.json — importable n8n workflow: webhook → schema validation → Telegram approver → audit log
  • docs/architecture.md — long-form rationale, channel matrix, threat model

MIT license. Drop the schemas into your stack — this is opinion + contracts, not a framework.

Related


This is Production AI Automation Notes #1. The series covers approval gates, MCP server security, Claude Code policies for company repos, n8n workflows with human approval, and audit-log schemas. Follow @renezander030 for the next entry.

Series — full list (updated 2026-08-02)

The Production AI Automation Notes series has grown to 16 entries:

  1. Agent Approval Gates — proposed actions, schema validation, audit log
  2. Token Budgets — per-step, per-pipeline, per-day enforcement
  3. Claude Code persistent memory between sessions (Agentic Task System) — your task app as the agent's memory layer; TickTick + Obsidian adapters, hybrid RRF retrieval
  4. Driving CapCut / JianYing from an LLM agent — deterministic JSON command boundary
  5. SQLite dedup + crash safety — WAL mode, seen_items, audit log
  6. Prompt-injection defense — input sanitization, schema validation, deterministic boundary
  7. PDF cite verification — auditable LLM extraction with per-fragment bounding boxes
  8. Stateless JSONL queue runner — wire a CLI into n8n / Make / Coze without an HTTP server
  9. LLM cost tracking — per-model price model + dollar spend on top of token budgets
  10. Deterministic step pipelines — fixed typed steps; the LLM never picks the next action
  11. Pipeline fixture testing — dry-run pipelines from JSON fixtures; zero API calls, deterministic CI
  12. LLM skills as YAML — prompt + output_schema + role in versioned YAML, validated by a linter
  13. Inbound agent webhook auth — constant-time bearer token, fail-closed on empty secret, async 202 dispatch
  14. Self-improving voice AI agent — human-approved prompt diffs, Dograh learning loop
  15. AI agent action audit trail in SQLite — who approved what and when, GDPR Art. 22 provenance
  16. Retrieval that degrades instead of failing — keyword fallback + RRF when the embedding provider dies; per-source status

Reference implementation for entries #1, #2, #5, #6, #7, #9, #10, #11, #12, #13: draftcat (Go, MIT).

Follow @renezander030 for new entries.

@Ar9av

Ar9av commented Jun 24, 2026

Copy link
Copy Markdown

The action boundary is the right place for approval.

If you ask too early, people approve a vague future. If you ask too late, the damage is done. The useful gate sits right where the agent is about to run the command, send the data, or cross the trust boundary.

That is also where the audit trail gets teeth, because the record matches a real decision instead of a rough intention.

@renezander030

Copy link
Copy Markdown
Author

Hi, exactly right. "Approve a vague future" is a great way to put it. That is why the gate keys off the proposed action object and not the plan: the proposal carries the concrete command, payload, and target, so what gets approved is what is about to run, nothing earlier. The dispatcher re-checks the ApprovalRecord at execution time too, so even a late expiry fails closed. Thanks for reading.

Rene

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment