A maker requests, a different checker approves — separation of duties enforced in the API guard, a DB CHECK constraint, and an immutable append-only audit trail.
Request to immutable sign-off — end to end
Three guards before any decision lands. Role gate (approver role), status gate (still pending), four-eyes gate (different person) — each failure is a clean typed error, never a silent write.
Belt-and-suspenders enforcement.decided_by <> requested_by fires in the API guard AND in a DB CHECK — a self-approval attempt gets GovernanceError 403 four_eyes either way.
Mail = email sent (fail-soft) Bell = in-app alert (fail-soft) Amber diamond = automatic check Amber card = error / trap path
The four-eyes rule fires TWICE — the API guard in decide() and a DB CHECK (decided_by <> requested_by) — so a code-path bypass still hits the wall. Every mutation writes its own immutable audit row; the table is UPDATE/DELETE-proof at the DB level for every role.
Why this is auditor-grade
The audit trail is append-only — UPDATE/DELETE on audit_log is blocked by DB triggers for everyone, including service_role.
A decision carries its rationale and a before/after snapshot — the row is the evidence.
Notifications are fail-soft: a delivery miss never blocks or reverses the recorded decision.
Live demo
app/approvals
Recorded from the running app. Maker submits → a different checker reviews and signs off → decision + rationale land in the append-only audit log.