The approval workflow — human-in-the-loop core¶
The centerpiece of the trust arc. Everything up to now — passwordless connect, verified edge claim, attribution patterns — earns its keep here: an agent proposes an action, a named human approves it, the effect executes attributably, and nothing about the chain relies on trust in the app code.
The rule this page enforces¶
No AI-generated effect commits without a named, authorized, distinct human approval — recorded immutably at the moment of decision.
Four load-bearing words:
- Named — the approver is a verified end-user immutable id from TA.2, not "the app", not "the agent", not "system".
- Authorized — the approver holds an explicit grant for this action kind, checked at approval time.
- Distinct — the approver is not the proposer (separation of duties). No one approves their own proposal.
- Immutably recorded — the decision + timestamp + approver id go into an append-only audit table at transition time, by trigger. The app cannot rewrite history after the fact.
Skip any one and the chain breaks. This page walks the state machine and the AUTHZ rules; implementation walks the DDL and the atomic transition semantics.
The state machine¶
┌───────────────┐
agent proposes ──►│ proposed │
└───────┬───────┘
│ (published for review)
▼
┌───────────────┐ withdrawn ┌────────────┐
│ pending │─────────────────────► │ withdrawn │
└───────┬───────┘ (by proposer only, └────────────┘
│ before decision)
│
┌────────┴────────┐
approve│ │reject
▼ ▼
┌───────────┐ ┌───────────┐
│ approved │ │ rejected │
└─────┬─────┘ └───────────┘
│ (effect executes exactly once)
▼
┌───────────┐
│ executed │
└───────────┘
Additionally:
pending ──── TTL expires ────► expired
Six terminal states — withdrawn, rejected, expired, executed — and two working states (proposed, pending). Every transition is a single atomic UPDATE with a WHERE-clause guard that refuses if the row is already past the pre-transition state.
AUTHZ — three checks, all at approval time¶
The one line every approve-handler runs before the UPDATE:
require(approver_oid != proposer_oid # distinct — separation of duties
AND approver has grant for action_kind # authorized — explicit permission
AND proposal.status == 'pending' # not already decided — idempotency
)
1. Distinct — separation of duties¶
The first check is the cheapest and the most important: the approver's verified oid must not equal the proposal's proposer_oid. This survives every kind of app bug — a UI that lets you click your own "approve" button, a mis-routed API call, a race where two tabs both open the same proposal. If both sides carry the same oid, the DB refuses:
CONSTRAINT approver_distinct_from_proposer
CHECK (approver_oid IS NULL OR approver_oid <> proposer_oid)
The DB is the enforcer, not the app. Put the check in one place; put it in the schema.
2. Authorized — explicit grant per action kind¶
Every proposal carries an action_kind (grant_role, publish_post, execute_sql, whatever the domain calls its effects). Approval requires the approver's oid to appear in a grants table for that kind:
CREATE TABLE approval_grant (
approver_oid text NOT NULL,
action_kind text NOT NULL,
granted_by text NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (approver_oid, action_kind)
);
The grants table is itself an audited table (with the audit-column pattern): granted_by is another named human's oid, so there's no way to self-elevate. Rotate rights by inserting / deleting rows; every change is attributable.
An EXISTS subquery in the approval WHERE clause makes the check part of the atomic transition — no TOCTOU race between "check grant" and "write approval":
UPDATE approval
SET status = 'approved', approver_oid = $1, decided_at = now()
WHERE id = $2
AND status = 'pending'
AND proposer_oid <> $1
AND EXISTS (SELECT 1 FROM approval_grant
WHERE approver_oid = $1
AND action_kind = approval.action_kind)
RETURNING id;
Zero rows returned ⇒ already decided OR would-be self-approval OR approver ungranted. The caller sees a single "cannot approve" outcome; the row's history is untouched.
3. Not already decided — idempotency¶
AND status = 'pending' is the load-bearing guard. Postgres's UPDATE ... WHERE ... RETURNING under READ COMMITTED evaluates the predicate after acquiring the row lock; two concurrent approvals collide and the second sees an updated row, re-checks status = 'pending', and returns zero rows. Double-click on Approve ⇒ one commit, one audit row, no double-execution. See implementation for the exact PG guarantees the docs make.
The AI hook¶
The state machine doesn't care whether the proposer is an agent or a human. But the interesting case is the agent one — because that's when the human-in-the-loop bar is doing real work:
proposer_oid= the agent's workload identity (agent-drafter@chiron.iamor the Entra service-principaloid).proposal.payload= the effect the agent wants to run (a diff, an SQL statement, a URL to purge, whatever).approver_oid= a verified humanoidfrom TA.2.- The
distinctcheck (separation of duties) rejects agent-self-approval by construction — agents can never approve their own proposals because theiroidis on both sides.
This is where the surface-intro AI-anchored write pattern grows up. The chiron.approver_oid session-config value on the executed row is the same approver_oid that transitioned this proposal to approved. The audit column and the approval workflow are the same story told twice: at the schema level (row-by-row attribution) and at the process level (state-machine gate).
Immutable audit — the append-only ledger¶
Every state transition — propose, approve, reject, withdraw, expire, execute — writes a row into approval_event via a trigger on approval. The app never writes to approval_event directly:
- No
DELETEgrants onapproval_eventto the workload role. - No
UPDATEgrants onapproval_eventto the workload role. - Only
INSERT, and only via the trigger — the trigger isSECURITY DEFINERso it can insert even when the workload role would be denied direct writes.
Result: even a compromised workload identity, given full app-level DB rights on the approval table, cannot forge or delete an audit event. The audit trail is a first-class DB object with its own permissions.
The exact DDL, the trigger, the atomic-transition CTE, and the fail-modes are on implementation.
Docs verified 2026-08-09¶
- PostgreSQL transaction isolation —
UPDATE ... WHERE ... RETURNINGre-checks predicate under READ COMMITTED: postgresql.org/docs/current/transaction-iso.html - PostgreSQL
SELECT ... FOR UPDATE SKIP LOCKEDfor approval-queue workers: postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE - PostgreSQL
CHECKconstraint semantics: postgresql.org/docs/current/ddl-constraints.html - PostgreSQL
SECURITY DEFINERfunctions for trigger-owned writes: postgresql.org/docs/current/sql-createfunction.html#SQL-CREATEFUNCTION-SECURITY
Where the arc goes next¶
- Implementation — the DDL, the atomic-transition SQL, the immutable-audit trigger, the executor idempotency contract.
- Later — approval flows across accounts / tenants (federated approvals), and quorum-N approval variants (M-of-N required).