Approvals, four-eyes and user approval#
An approval is a tool call that a rule held for a human decision, and that decision. When a rule's action is require_approval, the gateway does not forward the call; it creates an approval request and waits for people to decide. Approving lets exactly that one call run, once.
| Term | Meaning |
|---|---|
| approval | A held call. Status pending, then approved, denied or expired |
| approver | A person with the approver role, globally or for the call's namespace. Admins are not approvers unless they also hold the role |
| four-eyes approval | An approval that needs two (or more) different approvers: minApprovers 2 to 10 |
| user approval | An approval by the person the agent acts for, on their own device, through user approval on their own device (CIBA, OpenID Client-Initiated Backchannel Authentication) |
The life of an approval#
stateDiagram-v2
[*] --> pending: rule action require_approval
pending --> pending: one approval recorded,<br/>quorum not met (approve_partial)
pending --> approved: quorum met
pending --> denied: any approver denies<br/>(or the user rejects via CIBA)
pending --> expired: no decision within<br/>approvalTimeoutSeconds (default 300)
approved --> [*]: the held call runs once<br/>(consumed flag set)
denied --> [*]
expired --> [*]
- Identifiers. Each request gets an ID
apr_<24 hex digits>. - Expiry. A request not decided within its TTL (
approvalTimeoutSeconds, default 300 seconds) becomesexpired, which counts as a denial (approval_expired). Decided requests are kept for one hour. - Single use. An approval is bound to the session, server, tool and SHA-256 hash of the arguments. It can be consumed once; a second use is refused (
approval_invalid). - Durable. With
approvalStorePath(file) orstateBackend: postgres, pending requests, votes and the consumed flag survive restarts, so an approval cannot be replayed after a restart. With Postgres, every gateway replica sees every vote and a decision on one replica wakes a caller blocked on another.
Two ways to wait#
| Mode | How the agent asks | What happens |
|---|---|---|
| Synchronous (default) | A normal tools/call |
The gateway holds the HTTP request open until the approval is decided or expires, then forwards (or denies) the call |
| Asynchronous | Header X-Maqpna-Async: 1 |
The gateway answers at once with JSON-RPC error -32002 and {approvalId, expiresAt, policy, rule, reason}. The agent retries the identical call later with X-Maqpna-Approval-Id: <id>. MCP 2026-07-28 clients get an input_required result instead of -32002 |
Model calls and browser egress cannot wait for approval: a require_approval decision there is a denial (approval_required_unsupported_for_models, approval_required_not_supported_for_egress).
Separation of duties#
The approval store enforces these rules for every channel (API, CLI, console, Desk, Slack and Teams callbacks):
- No self-approval. A vote is refused (
self_approval) when the approver is the session's user or the agent's SPIFFE ID. - No double votes. A second vote by the same person is refused (
duplicate_approver). One static admin token counts as one person, so it can never satisfy four-eyes alone. - Approver groups. When the rule sets
approverGroups, the approver must be in one of them (approver_not_in_group). - Any deny is final. One deny ends the request, whatever the quorum.
- Verified identity. With OIDC admin authentication, the approver is taken from the verified access token, never from the request body.
Four-eyes quorum#
The quorum comes from two places, merged strictest wins:
- the matched rule's
approval: {minApprovers, approverGroups, userConfirmation}in theToolPolicy; - the gateway's
approvalQuorumentry for<policy>/<rule>(or itsdefault).
The larger minApprovers wins. When both restrict groups, the approver must be in one group of each list.
User approval (CIBA)#
A rule with approval.userConfirmation: ciba asks the person the agent acts for to confirm on their own device, through the customer's identity provider (for example Keycloak). The gateway sends a backchannel authentication request with login_hint set to the user and a binding message such as MAQPNA-1A2B3C4D/payments/create_transfer, then polls the identity provider until the user approves or rejects. Only the user can approve; approvers can still deny. If the user cannot be asked, ciba.fallback decides: approvers (default) lets approvers decide, deny refuses the call (user_confirmation_unavailable). See user approval (CIBA).
Where approvers decide#
| Surface | How |
|---|---|
| CLI | maqpna approvals list, maqpna approvals approve ID, maqpna approvals deny ID --note "reason" |
| Console | Approvals page (refreshes every 5 seconds, shows the last ten timeline steps) |
| MAQPNA Desk | Native notifications and a two-click approve; deny requires a reason |
| Chat | Slack and Microsoft Teams buttons (signed callbacks, mapped users, same role checks); webhooks and Matrix notify only |
| The user's device | CIBA push or prompt from the identity provider |
Approvers see an argument preview, never raw arguments by default: approvalPreview.mode is redacted (default; secrets and DLP findings masked), hash, none or full (raw arguments, secrets still masked).
What you see#
maqpna approvals list prints one row per approval (format from cmd/maqpna/approvals.go; values illustrative):
ID STATUS NAMESPACE AGENT TOOL REASON
apr_3f9c1a2b4d5e6f708192a3b4 pending team-a coder kubernetes/delete_pod destructive
Deciding prints the new status. While a four-eyes approval still needs votes, it says how many it has:
$ maqpna approvals approve apr_3f9c1a2b4d5e6f708192a3b4 --note "INC-123 cleanup"
apr_3f9c1a2b4d5e6f708192a3b4: pending (1 of 2 approvals; by bob@acme.eu)
$ maqpna approvals approve apr_3f9c1a2b4d5e6f708192a3b4
apr_3f9c1a2b4d5e6f708192a3b4: approved (by carol@acme.eu)
See maqpna approvals list, maqpna approvals approve, maqpna approvals deny and maqpna desk.