An append-only approval-audit table for human-in-the-loop AI pipelines: prove which operator approved which proposed action, what they actually saw, and when — and find actions that executed with no approval at all.
Last tested: June 2026. See Changelog at the bottom.
If this saves you setup time, follow @renezander030 — production AI agent governance in Go.
Reference implementation (MIT, Go): github.com/renezander030/draftcat.
When an AI agent proposes an action and a human approves it before it executes, you have a compliance obligation the demo never mentions: months later you must be able to answer "who approved this specific automated decision, what exactly did they see when they approved it, and can you prove the ones that ran were all approved?" That is GDPR Article 22 (automated decision-making) and the EU AI Act logging duty in one question.
Most pipelines log that a run happened and that an action was approved: true. That is not an audit trail. An auditor does not care that a boolean was set; they care which named human approved which exact payload, and whether anything slipped through ungated. This gist is the SQLite schema + queries that answer those questions.
| Question an auditor asks | Query / mechanism |
|---|---|
| Who approved this action? | decided_by column — operator identity, not "operator" |
| What did they actually see? | payload_json frozen at decision time + payload_hash |
| When? | requested_at + decided_at (request and decision are separate) |
| Did anything execute without approval? | LEFT JOIN side-effects → approvals WHERE approval IS NULL |
| Every decision by one operator in a window | index on (decided_by, decided_at) |
| All automated decisions affecting a subject | filter action_type + search payload_json |
| Can I trust the log wasn't edited? | append-only: no UPDATE/DELETE; corrections are new rows |
- Freeze the payload the human saw, not a pointer to it. Store the full proposed action JSON and its hash at decision time. If you store only an ID and the underlying record later changes, you can no longer prove what was approved.
- Record identity, not role.
decided_by = "telegram:525..."/ an SSO subject / an email — never the literal string"operator". "A human approved it" is not auditable; "this human approved it" is. - Append-only or it is not evidence. No
UPDATE, noDELETE. A reversal is a new row withdecision='revoke'. A mutable log is worthless in an audit because you cannot prove it was not edited after the fact.
draftcat already ships two SQLite tables, and the audit trail is neither:
seen_itemsanswers "did I already process this item?" — idempotency and crash-safety. (SQLite dedup + crash safety, PAAN #5.)pipeline_runsanswers "did this run succeed or error, and when?" — operational forensics.
// internal/state/state.go — the existing run-forensics table (baseline)
CREATE TABLE IF NOT EXISTS pipeline_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pipeline TEXT NOT NULL,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
status TEXT NOT NULL,
error_text TEXT
);Neither table records who approved which proposed action. The operator's decision flows through the pipeline as data["approved"] = true and is then thrown away. The table below is what you add to keep it.
Create it in the same SQLite file draftcat already opens (shared WAL journal), so the audit trail survives crashes exactly like the dedup data does.
CREATE TABLE IF NOT EXISTS action_approvals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER REFERENCES pipeline_runs(id), -- ties the decision to run forensics + the side effect
pipeline TEXT NOT NULL,
step TEXT NOT NULL, -- which gate: 'approve-diff', 'approve-deploy', ...
action_type TEXT NOT NULL, -- what was proposed: 'workflow_publish', 'email_send', ...
payload_hash TEXT NOT NULL, -- sha256 of the exact payload the human saw
payload_json TEXT NOT NULL, -- the full proposed action, frozen at decision time
decision TEXT NOT NULL CHECK (decision IN ('approve','skip','adjust','revoke')),
decided_by TEXT NOT NULL, -- operator identity (telegram user id / SSO subject / email)
channel TEXT NOT NULL, -- telegram / slack / email
requested_at INTEGER NOT NULL, -- when the agent asked
decided_at INTEGER NOT NULL, -- when the human answered
note TEXT -- optional operator comment / adjust text
);
CREATE INDEX IF NOT EXISTS idx_approvals_pipeline ON action_approvals(pipeline, decided_at DESC);
CREATE INDEX IF NOT EXISTS idx_approvals_operator ON action_approvals(decided_by, decided_at DESC);
CREATE INDEX IF NOT EXISTS idx_approvals_action ON action_approvals(action_type, decided_at DESC);draftcat's approval gate already returns an OperatorDecision{Action: "approve"|"skip"|"adjust"} from SendForApproval. Persist it right there — the only new inputs are the operator's identity and the frozen payload:
type ApprovalRecord struct {
RunID int64
Pipeline string
Step string
ActionType string
PayloadJSON string // the exact proposed action serialized for the operator
Decision string // decision.Action
DecidedBy string // who clicked — from the channel callback, NOT a constant
Channel string
RequestedAt time.Time
DecidedAt time.Time
Note string
}
// RecordApproval appends one operator decision. Append-only: never UPDATE/DELETE.
// The payload is frozen (full JSON + hash) so you can later prove WHAT was
// approved, not merely that an approve happened.
func (s *StateStore) RecordApproval(a ApprovalRecord) error {
sum := sha256.Sum256([]byte(a.PayloadJSON))
_, err := s.db.Exec(`
INSERT INTO action_approvals
(run_id, pipeline, step, action_type, payload_hash, payload_json,
decision, decided_by, channel, requested_at, decided_at, note)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
a.RunID, a.Pipeline, a.Step, a.ActionType,
hex.EncodeToString(sum[:]), a.PayloadJSON,
a.Decision, a.DecidedBy, a.Channel,
a.RequestedAt.Unix(), a.DecidedAt.Unix(), nullIfEmpty(a.Note),
)
return err
}Wire it at the existing call site (main.go, where the "approval" step type handles the operator response):
decision, err := ch.SendForApproval(approvalCtx, draftMsg)
approvalCancel()
// ... existing handling sets data["approved"] ...
_ = state.RecordApproval(state.ApprovalRecord{
RunID: runID,
Pipeline: pipe.Name,
Step: step.Name,
ActionType: step.ActionType, // e.g. "workflow_publish"
PayloadJSON: draftMsg.PayloadJSON, // exactly what the operator was shown
Decision: decision.Action,
DecidedBy: decision.OperatorID, // captured from the TG/Slack callback
Channel: ch.Name(),
RequestedAt: requestedAt,
DecidedAt: time.Now(),
Note: decision.AdjustText,
})Recording is best-effort for availability (a failed insert must not crash the engine) but the insert itself is the evidence — log loudly if it fails.
1. Everything one operator approved in a window (access review, leaver offboarding):
SELECT decided_at, pipeline, action_type, payload_hash
FROM action_approvals
WHERE decided_by = ? AND decision = 'approve'
AND decided_at BETWEEN ? AND ?
ORDER BY decided_at DESC;2. Provenance for one shipped side effect — who approved it and what they saw:
SELECT a.decided_by, a.decided_at, a.payload_hash, a.payload_json
FROM action_approvals a
JOIN pipeline_runs r ON r.id = a.run_id
WHERE a.action_type = 'workflow_publish'
AND a.decision = 'approve'
AND r.id = ?; -- the run that performed the publish3. The red-flag query: actions that executed with NO approval. This is how you prove the gate actually held. Join your side-effect log (here pipeline_runs that performed a gated action) against approvals:
SELECT r.id, r.pipeline, r.ended_at
FROM pipeline_runs r
LEFT JOIN action_approvals a
ON a.run_id = r.id AND a.decision = 'approve'
WHERE r.status = 'ok'
AND r.pipeline IN (SELECT pipeline FROM gated_pipelines) -- pipelines that REQUIRE approval
AND a.id IS NULL; -- ran successfully, no approval on record == gate violationA non-empty result here is an incident, not a report.
4. Article 22 subject request — all automated decisions touching one data subject:
SELECT decided_at, action_type, decision, decided_by
FROM action_approvals
WHERE payload_json LIKE '%' || ? || '%' -- subject identifier (email / contact id)
ORDER BY decided_at;For high volume, promote the subject id to its own indexed column instead of LIKE.
- Why not just application logs (stdout/JSON lines)? Logs rotate, are mutable, and are not queryable by "which human approved action X." They prove an event was emitted, not that a decision is intact and attributable.
- Why not reuse
pipeline_runs? It is run-grained (one row per run, success/error). A single run can contain several approval gates (propose-diff, then deploy). Decisions are their own grain. - Why store the full payload, not a foreign key to the proposed action? Because the referenced record can change after approval. The frozen JSON + hash is the only thing that proves what the human saw at the moment they approved.
FOREIGN KEY constraint failed on insert — SQLite enforces FKs only after PRAGMA foreign_keys=ON. Either enable it and insert the pipeline_runs row first, or keep run_id a plain nullable integer and enforce the link in code.
payload_hash differs from what shipped — you hashed a re-serialized struct, not the exact bytes shown to the operator. Hash the same string you rendered into the approval message; serialize once, reuse it for display, hash, and storage.
database is locked — concurrent writers without WAL. draftcat opens the DB with PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; reuse that connection rather than opening a second handle to the same file.
sqlite3 state.db < schema.sql # the action_approvals DDL above
# Simulate an approved publish
sqlite3 state.db "INSERT INTO action_approvals
(run_id,pipeline,step,action_type,payload_hash,payload_json,decision,decided_by,channel,requested_at,decided_at)
VALUES (1,'voice-guardrail','approve-deploy','workflow_publish','abc123',
'{\"workflow_id\":42,\"diff\":\"raise VPC price quote\"}','approve','telegram:owner','telegram',
strftime('%s','now')-30, strftime('%s','now'));"
# Provenance query (query 1)
sqlite3 -header -column state.db \
"SELECT decided_at,action_type,decided_by FROM action_approvals WHERE decided_by='telegram:owner';"Pass criteria: the row comes back with a real decided_by and an action_type, and the gate-violation query (#3) returns zero rows for that run.
approved BOOLEANon the work item and nothing else. It cannot answer "who" or "what did they see," which are the only questions an audit asks.- Logging the decision only to stdout. Not queryable, not tamper-evident, gone on rotation.
- Storing a pointer to the proposed action instead of a frozen copy. The pointed-at record drifts; your "proof" no longer matches what was approved.
- Allowing
UPDATE/DELETEon the table. The moment the log is mutable it stops being evidence. Reversals are new rows.
This is Production AI Automation Notes #15 — a series on shipping AI agents outside demos. Reference implementation: draftcat (Go, MIT).
Related entries:
- #1: Agent Approval Gates — the human-review gate whose decisions this table records.
- #5: SQLite Dedup + Crash Safety — the
seen_itemstable; idempotency, not provenance. - #13: Inbound Agent Webhook Auth — constant-time bearer token on the receivers.
- #14: Self-improving voice agent — human-approved prompt diffs — a two-gate loop whose every approval should land in this table.
Follow @renezander030 for the next entry.
- draftcat
internal/state/state.go(the realseen_items+pipeline_runsschema this extends),main.goapproval-step handling - GDPR Article 22 (automated individual decision-making); EU AI Act logging/record-keeping obligations for high-risk systems
Running an approval audit on a different store (Postgres, DynamoDB, an event log)? Comment with: your table grain, whether you freeze the payload or reference it, how you capture operator identity from the approval channel, and whether your gate-violation query has ever returned a non-empty result. I'll fold the patterns back in.
- Initial publish. Append-only
action_approvalsschema + Go recorder + four audit queries, extending draftcat's existinginternal/statestore. - Deliberate gate skips: no hardware×model table (gate 13 — not hardware-bound); no companion repo (draftcat is the reference repo, consistent with #11–#14).