Skip to content

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.iam or the Entra service-principal oid).
  • proposal.payload = the effect the agent wants to run (a diff, an SQL statement, a URL to purge, whatever).
  • approver_oid = a verified human oid from TA.2.
  • The distinct check (separation of duties) rejects agent-self-approval by construction — agents can never approve their own proposals because their oid is 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 DELETE grants on approval_event to the workload role.
  • No UPDATE grants on approval_event to the workload role.
  • Only INSERT, and only via the trigger — the trigger is SECURITY DEFINER so 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

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