Skip to content

Instantly share code, notes, and snippets.

@renezander030
Created June 17, 2026 16:24
Show Gist options
  • Select an option

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

Select an option

Save renezander030/ad81c7a805a09a844983f881e2c487e5 to your computer and use it in GitHub Desktop.
AI agent action audit trail in SQLite: who approved what, when (GDPR Art. 22)

AI agent action audit trail in SQLite: who approved what, when (GDPR Art. 22)

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.

TL;DR cheat sheet

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

The audit-trail rule of thumb

  • 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, no DELETE. A reversal is a new row with decision='revoke'. A mutable log is worthless in an audit because you cannot prove it was not edited after the fact.

This is not the dedup table or the run log

draftcat already ships two SQLite tables, and the audit trail is neither:

  • seen_items answers "did I already process this item?" — idempotency and crash-safety. (SQLite dedup + crash safety, PAAN #5.)
  • pipeline_runs answers "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.

Recommended setup: one append-only approvals table

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);

Record the decision (Go, mirrors the existing RecordRun)

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.

The four queries that matter

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 publish

3. 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 violation

A 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 an append-only table instead of the alternatives

  • 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.

Common failures (paste-into-Google edition)

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.

Smoke test

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.

Setups I would avoid

  • approved BOOLEAN on 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/DELETE on the table. The moment the log is mutable it stops being evidence. Reversals are new rows.

Series

This is Production AI Automation Notes #15 — a series on shipping AI agents outside demos. Reference implementation: draftcat (Go, MIT).

Related entries:

Follow @renezander030 for the next entry.

Sources

  • draftcat internal/state/state.go (the real seen_items + pipeline_runs schema this extends), main.go approval-step handling
  • GDPR Article 22 (automated individual decision-making); EU AI Act logging/record-keeping obligations for high-risk systems

Reader contributions

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.

Changelog

2026-06-17

  • Initial publish. Append-only action_approvals schema + Go recorder + four audit queries, extending draftcat's existing internal/state store.
  • Deliberate gate skips: no hardware×model table (gate 13 — not hardware-bound); no companion repo (draftcat is the reference repo, consistent with #11–#14).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment