MAQPNADocs

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#

  1. 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: redacted by default, or none, hash, full). Raw arguments are kept only in full mode. The ID is apr_ plus 24 hex digits; expiresAt is now plus approvalTimeoutSeconds (default 300).
  2. Set the quorum. The rule's approval {minApprovers, approverGroups, userConfirmation} and the gateway's approvalQuorum entry for <policy>/<rule> (or default) are merged, strictest wins. With userConfirmation: ciba, the user approval flow starts too.
  3. Announce. approval.created is appended to the notifier outbox (notifyOutboxPath, or the shared Postgres journal notify-outbox). Each notifier (webhook with HMAC signature, slack Block Kit buttons, teams Adaptive Card, matrix notice) 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 and maqpna approvals list read GET /v1/approvals directly.
  4. 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 decision require_approval and reason approval_pending:<id>, and returns JSON-RPC -32002 with {approvalId, expiresAt, policy, rule, reason}. An MCP 2026-07-28 client gets an input_required result whose requestState is maqpna-approval:<id> instead.
  5. Decide. An approver calls POST /v1/approvals/{id}/approve or /deny with {note}. The store checks, under a cluster-wide lock with Postgres: - the caller holds approver globally 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's approver is used. Chat callbacks map the Slack or Teams user to a MAQPNA identity and apply the same checks.
  6. Count. Below minApprovers, the vote is journaled as approve_partial and approval.partial is notified. At the quorum the status becomes approved. Any deny is final (denied).
  7. Expire. With no decision by expiresAt, the request is expired, approval.expired is notified and the waiting call is denied with approval_expired (<id>). Expiry is derived from the timestamp on every replica, and a decision recorded before the deadline wins.
  8. 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).
  9. 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 allow record carries approver, reason approval:approved by <approver> (<id>), ext.approvers, ext.approverIss and ext.approverGroups.
  10. Retry (asynchronous). The agent resends the identical call with X-Maqpna-Approval-Id: <id> (or params.requestState). The approval must match session, namespace, agent, server, tool and argument hash. Still pending: -32002 again, 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