2026-06-29 · fundamentals
Suggest, don't act: approval queues and the table that can't forget
Week 11 of the fundamentals series: why high-stakes AI should propose rather than act, and what a trustworthy approval queue needs underneath it. The build is a Postgres audit table where UPDATE and DELETE fail by trigger, walked through line by line.
Week 11 of the fundamentals series: approval queues and audit trails, the two structures that let a probabilistic system operate in a domain where its mistakes are expensive. They're a pair on purpose. The queue is how a human stays the deciding voice, and the trail is how you prove, later, what was decided and by whom. Neither is AI engineering. Both are what make the AI shippable.
Why suggest-then-approve beats act-then-apologize
The argument is the series in miniature. Week 7 established that a model will eventually produce a confident wrong output, mechanically, no matter how good it gets. Week 10 established that the fix is designing what the system can express. An approval queue is the strongest version of that design: for actions above some cost of error, the model's entire expressible range is a suggestion in a table. The action happens when a person moves it.
What surprised me in practice is how much the queue gives back. PermitCheck runs a review queue where low-confidence and edge-case submissions get flagged for human eyes, and each review records the reviewer's verdict and notes next to what the system thought. That stream of disagreements is the highest-value data I collect: every row where the human overrode the machine is a bug report against my rules, filed by an expert, for free.
Enacted has the pattern in a different cut. Summaries that pass the citation gate cleanly publish with reviewStatus: "auto". A summary the gate caught and a retry fixed publishes as "verified", and one that couldn't be fixed gets flagged for me. The distinction between clean-first-time and corrected is stored, permanently, instead of being erased the moment a summary passes. Whether a claim needed correction is itself a fact worth keeping.
The build: a table that refuses to forget
An approval queue is only as trustworthy as its history, and history in a normal database table is editable, which means it's an assertion, not a record. So this week's build is the smallest version of the real thing: a Postgres audit table where UPDATE and DELETE are impossible by trigger. Here's the whole build, piece by piece.
create table suggestions (
id bigint generated always as identity primary key,
payload jsonb not null, -- what the model wants to do
status text not null default 'pending'
check (status in ('pending', 'approved', 'rejected', 'applied')),
decided_by text,
decided_at timestamptz
);
create table audit_log (
id bigint generated always as identity primary key,
suggestion_id bigint not null references suggestions(id),
event text not null, -- proposed | approved | rejected | applied
actor text not null, -- 'model' or a human identifier
detail jsonb not null,
created_at timestamptz not null default now()
);
Two tables because they have opposite lifecycles. A suggestion is supposed to change state, pending to approved to applied, so it's a normal mutable row. The log records that each transition happened, and a record of the past has no legitimate reason to change. So we make change impossible:
create function audit_log_is_append_only()
returns trigger language plpgsql as $$
begin
raise exception 'audit_log is append-only';
end $$;
create trigger audit_log_no_rewrite
before update or delete on audit_log
for each row execute function audit_log_is_append_only();
Why each piece is there. The function does one thing, raise, because there is no case where the operation should proceed. The trigger binds it before update or delete, so the exception fires before any row is touched, and for each row means a sweeping UPDATE audit_log SET ... dies on its first row. Inserts aren't mentioned, so inserts flow normally: append-only in one trigger.
Why a trigger instead of just revoking permissions? Do both, but the trigger catches the case grants can't: the application's own service role, the one with write access, having a bad day. A migration with a careless WHERE, an ORM cascade you forgot about, a well-intentioned cleanup script. The trigger makes the table safe from the code you trust, which is the code that actually corrupts audit trails in practice.
And the honest limit: a database owner can drop the trigger. This isn't cryptographic immutability, and pretending otherwise would be its own integrity failure. What the trigger buys is that no code path can casually rewrite history. Tampering becomes a loud, deliberate act that itself leaves traces in migrations and logs, and for most systems that's the property you actually need.
The flow over these tables is short:
Model proposes
A row in suggestions, payload validated against a schema first (week 6), plus a 'proposed' event in the log.
Human decidesgate
Approve or reject, with identity and timestamp recorded on the suggestion and a matching event appended to the log.
Code applies
Only approved suggestions execute, and the application writes an 'applied' event with the outcome.
Nobody edits historygate
The log takes inserts and nothing else. Corrections are new events that say a previous event was wrong.
That last step carries the accounting worldview this all borrows from: you don't erase a wrong ledger entry, you append a correcting one. The mistake and the correction are both history.
The pieces you already have
If you've been following the series, you have most of this already. The mutation layer from week 10 already logs every action with an idempotency key and a before-image, which is an audit trail by another name. The review queue is a suggestions table wearing domain clothes. The one new idea this week is the refusal to forget, and it costs eleven lines of SQL.
The question for your own system is the same one an auditor would ask: when your AI did the wrong thing four months ago, can you reconstruct what it proposed, who approved it, and what actually ran? If the answer depends on nobody having edited a table since, you have a story, not a trail.