Approval flow#
When the decision on a tool call is require_approval, the MAQPNA gateway does not forward it. It creates an approval request in the approval store (pkg/approval), announces it to approvers, and either holds the agent's HTTP request open (synchronous) or answers at once with an approval ID (asynchronous). A decision lets exactly that call, with exactly those arguments, run once. This page follows the code in cmd/maqpna-gateway/mcp.go (handleApproval), approvals_api.go, notifiers.go and pkg/approval.
A call can be held by a policy rule, by a budget with onExceed: require_approval, or by a tool pin with onChange: approve. Model calls (/llm) and browser egress are never held; a require_approval decision there is a denial.
States#
stateDiagram-v2
[*] --> pending: Create (apr_ id, expiresAt = now + approvalTimeoutSeconds)
pending --> pending: approve vote, quorum not met<br/>(approve_partial, event approval.partial)
pending --> approved: votes reach minApprovers<br/>(or the user confirms via CIBA)
pending --> denied: any deny vote
pending --> expired: TTL passed without a decision
approved --> approved: Consume (consumed flag, once)
approved --> [*]
denied --> [*]
expired --> [*]
Sequence (synchronous, four-eyes)#
sequenceDiagram
autonumber
participant Ag as Agent
participant GW as Gateway
participant AQ as Approval store
participant NO as Notifier outbox
actor A1 as Approver 1
actor A2 as Approver 2
participant L as Audit ledger
Ag->>GW: tools/call payments.create_transfer
GW->>GW: policy says require_approval, minApprovers 2
GW->>AQ: Create (session, server, tool, argsSha256, preview, quorum)
AQ-->>NO: approval.created
NO-->>A1: Slack, Teams, webhook or Matrix message
GW->>AQ: Wait (HTTP request held open)
A1->>GW: POST /v1/approvals/ID/approve (OIDC token, note)
GW->>AQ: Decide vote 1
AQ-->>NO: approval.partial
A2->>GW: POST /v1/approvals/ID/approve
GW->>AQ: Decide vote 2, status approved
AQ-->>NO: approval.decided
AQ-->>GW: Wait returns approved
GW->>AQ: Consume (single use)
GW->>GW: re-check the kill switch
GW->>GW: forward to the MCP server
GW->>L: allow, approver, ext.approvers, ext.approverIss
GW-->>Ag: result
Step by step#
- Create. The gateway builds the request: namespace, agent, session, user, the agent's SPIFFE ID as
subject, server, tool,argsSha256, the deciding policy, rule and reason, and an argument preview (approvalPreview.mode:redactedby default, ornone,hash,full). Raw arguments are kept only infullmode. The ID isapr_plus 24 hex digits;expiresAtis now plusapprovalTimeoutSeconds(default 300). - Set the quorum. The rule's
approval {minApprovers, approverGroups, userConfirmation}and the gateway'sapprovalQuorumentry for<policy>/<rule>(ordefault) are merged, strictest wins. WithuserConfirmation: ciba, the user approval flow starts too. - Announce.
approval.createdis appended to the notifier outbox (notifyOutboxPath, or the shared Postgres journalnotify-outbox). Each notifier (webhookwith HMAC signature,slackBlock Kit buttons,teamsAdaptive Card,matrixnotice) gets it at least once, with exponential backoff up to 12 attempts. With several gateway replicas, one replica claims each delivery. The console, MAQPNA Desk andmaqpna approvals listreadGET /v1/approvalsdirectly. - Hold or return.
- Synchronous (default): the gateway waits on the store (span
approval.wait) until a decision or expiry. A decision taken on another replica wakes the waiter through the shared store. - Asynchronous (X-Maqpna-Async: 1): the gateway writes a ledger record with decisionrequire_approvaland reasonapproval_pending:<id>, and returns JSON-RPC-32002with{approvalId, expiresAt, policy, rule, reason}. An MCP 2026-07-28 client gets aninput_requiredresult whoserequestStateismaqpna-approval:<id>instead. - Decide. An approver calls
POST /v1/approvals/{id}/approveor/denywith{note}. The store checks, under a cluster-wide lock with Postgres: - the caller holdsapproverglobally or in the approval's namespace (admins are not implicitly approvers); - the voter is neither the session's user nor the agent (409 self_approval); - the voter has not voted already (409 duplicate_approver; one static admin token counts as one person); - the voter is in the required groups (403 approver_not_in_group); - for a CIBA approval, only the user can approve (409 user_confirmation_required); approvers can still deny. With OIDC, the approver is the verified username from the token; with the static token, the body'sapproveris used. Chat callbacks map the Slack or Teams user to a MAQPNA identity and apply the same checks. - Count. Below
minApprovers, the vote is journaled asapprove_partialandapproval.partialis notified. At the quorum the status becomesapproved. Any deny is final (denied). - Expire. With no decision by
expiresAt, the request isexpired,approval.expiredis notified and the waiting call is denied withapproval_expired (<id>). Expiry is derived from the timestamp on every replica, and a decision recorded before the deadline wins. - Consume. An approved request is consumed once. If consumption fails (already redeemed, or the journal write failed), the call is denied
approval_invalid(security review fix SR-08). - Re-check and forward. The kill switch is checked again, then the call continues at the intent record and forward steps of the tool call. The
allowrecord carriesapprover, reasonapproval:approved by <approver> (<id>),ext.approvers,ext.approverIssandext.approverGroups. - Retry (asynchronous). The agent resends the identical call with
X-Maqpna-Approval-Id: <id>(orparams.requestState). The approval must match session, namespace, agent, server, tool and argument hash. Still pending:-32002again, not audited. Approved: consumed and forwarded. Denied:approval_denied by <approver>.
What you see#
$ maqpna approvals list --gateway https://gw.example.eu
ID STATUS NAMESPACE AGENT TOOL REASON
apr_3f9c1a2b4d5e6f708192a3b4 pending team-a coder payments/create_transfer four-eyes for large transfers
$ maqpna approvals approve --gateway https://gw.example.eu apr_3f9c1a2b4d5e6f708192a3b4 --note "INV-2210"
apr_3f9c1a2b4d5e6f708192a3b4: pending (1 of 2 approvals; by bob@acme.eu)
The formats come from cmd/maqpna/approvals.go; values are illustrative. An asynchronous agent sees:
{"jsonrpc":"2.0","id":9,"error":{"code":-32002,"message":"approval required (four-eyes for large transfers); retry with header X-Maqpna-Approval-Id once approved","data":{"approvalId":"apr_3f9c1a2b4d5e6f708192a3b4","detail":"four-eyes for large transfers","domain":"maqpna.com","expiresAt":"2026-10-02T14:10:09Z","policy":"payments","reason":"approval_required","rule":"big-payment"}}}
See maqpna approvals list, maqpna approvals approve, maqpna approvals deny and maqpna approvals watch.
Failure modes#
| Situation | Behaviour |
|---|---|
| Gateway restarts with a file store | Pending requests, votes and the consumed flag are replayed from the journal; requests that expired meanwhile become expired |
No approvalStorePath and file backend |
The queue is in memory; pending approvals are lost on restart (fail closed) |
| Postgres unreachable | Creating, deciding and consuming approvals fail closed |
| Notifier endpoint down | The outbox retries for up to 12 attempts; decisions still work through the API, console and Desk |
| Agent disconnects during a synchronous wait | The call is denied approval_aborted; the request stays pending until it is decided or expires |