OutputValues icon
Query icon
If icon
Sequential icon
SlackIncomingWebhook icon
Pause icon
Labels icon
Fail icon
Log icon
Webhook icon

Governed Action Executor with Human Approval Gate

Pause for human approval, check the decision explicitly, then purge rows and record the result in one atomic Postgres statement. Webhook or manual start.

Categories
AIInfrastructure

Executor of the governed MCP actions pattern. It is started by mcp-governed-action-request, which also documents setup, secrets and the security model: read it first. Companion flow: mcp-governed-action-status.

How it works

  1. resolve_key takes the key from the key input or from {"key": "..."} in a webhook body.
  2. claim_request moves the row from claimed to pending (the environment needs approval) or running (it does not) in one UPDATE ... RETURNING. It only matches a key that fits ^[A-Za-z0-9_-]{1,64}$ (checked in SQL, so a webhook body cannot bypass it) and a row with no executor yet, or this one. Zero rows means nothing to do.
  3. For approval environments, notify_approvers posts a Slack message with a link to this execution (3 attempts). If all fail, the row becomes pending_unnotified, the approval keeps waiting, and the execution ends with a WARNING.
  4. wait_for_approval pauses for 1 hour (behavior: FAIL). The decision defaults to deny.
  5. authorization_gate runs the purge only for an explicit approve, or when no approval is required. purge_and_record is one SQL statement: the DELETE and the update to executed commit or roll back together, and both require the row to be running for this execution.
  6. Errors: pending or pending_unnotified becomes expired, running becomes failed.

What you get

  • A claim that decides whether approval is needed from the row and a fixed list of environments, never from an input.
  • A Slack approval message with a link to the paused execution, retried, with a recorded status when it fails.
  • A one-hour approval window, a decision that defaults to deny, and a purge that only runs for an explicit approve.
  • A result recorded in the row (executed, rejected, expired or failed) and labels on the execution (environment, decision, decided_by).
  • A webhook and a manual run that can only act on a row in status claimed.

Who it's for

Teams that run the request flow and need to know what happens after an agent asks for a production action. It also works on its own for any claimed row, started by hand or by its webhook, if you want the approval-then-purge half without the MCP tool.

Why orchestrate this with Kestra

A shell script can delete rows, but it cannot wait an hour for a person without holding anything open, keep the decision next to the execution, retry a notification, or turn a failure into a recorded status. Here the Pause holds the approval, the errors block records expired or failed, labels show who decided what, and the purge only deletes while the row is running for this execution, so a Restart after a recorded failure deletes nothing.

Tested end to end

On Kestra 2.0.4 OSS with Postgres 16, as a least-privilege role, with a local Slack stub.

  • Staging runs at once; production waits, and approve, deny, a resume with no fields and a resume with a wrong field behave as described. Nothing is deleted before approval or after a deny.
  • Slack down: the row becomes pending_unnotified and approving still works.
  • A real Pause expiry (tested with a 20-second copy of the pause) ends in expired with nothing deleted.
  • Restart after a failed purge deletes nothing and fails with "Nothing was deleted". A stuck claimed row is recovered by a direct run or a Restart.
  • Webhook runs on a claimed row; a wrong webhook key returns 404.
  • Five simultaneous approvals of the same pause: one accepted, four answered 409, one DELETE.
  • 25 keys with 50 webhook calls racing the starting executor: every key executed exactly once. For 5 of the 10 production keys the webhook won the claim, so the id returned to the agent pointed to an executor that did nothing.

Prerequisites

  • Postgres with the tables from the Database setup in mcp-governed-action-request (public.governed_requests and <schema>.demo_events).
  • Secrets: PG_PASSWORD (database password), SLACK_WEBHOOK_URL (Slack incoming webhook) and GOVERNED_EXECUTOR_WEBHOOK_KEY (webhook key, a long random value).
  • Replace the placeholders in variables: jdbc_url, jdbc_user and kestra_url. Change kestra_url to your own Kestra URL: it is a placeholder (http://localhost:8080), and the approval link in the Slack message is built from it because kestra.url is unset in Kestra 2.0.4. Left as is, the link points to localhost.

Quick start

  1. Import this flow first, then mcp-governed-action-request and mcp-governed-action-status, all into the same namespace.
  2. To try it without the MCP tool, insert a request row: INSERT INTO public.governed_requests (request_key, environment, days, status) VALUES ('demo-1', 'staging', 30, 'claimed');
  3. Run this flow with key set to demo-1. A staging row runs the purge at once. To see the approval, insert a second row with a new key and production (for example demo-2) and run the flow with that key: the execution pauses. Open it from the Slack link, or from the Executions list, and choose approve or deny.

How to extend

Ideas, not tested in this blueprint:

  • Replace the demo DELETE in purge_and_record with your own action and keep its guards.
  • Change the approval window by editing the literal pauseDuration. It has to stay a literal: templated Pause settings are ignored in Kestra 2.0.4.
  • Replace notify_slack inside notify_approvers with your own channel or ticket system. Keep that block's allowFailure and record_unnotified so a failed notification stays visible.
  • Replace the self-asserted approver with a checked identity, for example by approving through your own front end.

Inputs

  • key (STRING, optional, ^[A-Za-z0-9_-]{1,64}$): request key of a row created by mcp-governed-action-request. Environment and days are read from the row, never from inputs. A webhook run passes it as {"key": "..."}.

Known limits

  • Direct and webhook runs cannot bypass approval, but anyone with the key can take over a claimed request, so the id returned to the agent may then point to a no-op execution. The webhook needs no Kestra credentials: its key, read from the GOVERNED_EXECUTOR_WEBHOOK_KEY secret, is the only protection.
  • The approver name is self-asserted text, not an authenticated identity. Anyone who can resume the execution can approve.
  • If the status is pending_unnotified, nobody was told. Nothing re-sends the link on its own: calling governed_action_status re-sends it once the row has been pending_unnotified for 10 minutes.
  • A killed executor or a lost worker leaves the row in pending, pending_unnotified or running. running means the purge statement had not committed (it is atomic). After checking in the Executions list that the executor is really gone, reset it: UPDATE public.governed_requests SET status = 'failed', updated_at = now() WHERE request_key = '<key>' AND status IN ('pending', 'pending_unnotified', 'running'); The key can then be requested again with the same environment and days.
  • A row stuck in claimed: if no executor was started, run this flow by hand with the key, call its webhook, or Restart the failed request execution. If an executor was started but failed before claiming, Restart that executor execution: the claim only accepts the executor recorded on the row.
  • Restart or Replay: Replay finds no claimed row and does nothing. A Restart re-runs the failed task. After a recorded failure the purge deletes nothing and the execution fails with "Nothing was deleted". If recording failed also failed (for example the database was down), the row stays running and a Restart can re-run the purge with the old approval.

Links

This blueprint was created by daiwangk.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.