# An approval gate your executor doesn't enforce is only a suggestion

> An agent skill's stop-and-ask step is only safe if the step runner enforces it and the run state can hold a question. Here's what broke, and a check you can run.

- Author: Chad Priest
- Published: 2026-09-22
- Canonical URL: https://blog.vodou.ai/agent-approval-gates-executor-enforced/
- Tags: ai-agents, mcp, architecture, llm

---

Most agent stacks now have some kind of "skill": a markdown file that tells a model how to do a multi-step job. Most of those files also contain a line like "ask the user before sending." That line is prose, and a model reads it at runtime. Nothing guarantees the model stops there. If your approval step lives in the instructions and not in the thing that runs the steps, you have a hope where you think you have a gate.

I spent a stretch of late August turning that line into something an executor enforces. I built it into [Vodou](https://vodou.ai/register?utm_source=blog&utm_medium=capability&utm_campaign=skills), a local-first AI system with persistent memory and MCP tool orchestration. Most of what I learned applies to any runner that fans out tool calls and waits on a person.

## A skill that parks at `ask me:` until someone picks an option

The idea is small. You write the workflow once as a markdown skill and run it by name. The skill can declare stopping points. When the run reaches one, the engine stops, shows a numbered menu, and does nothing else until you pick. A skill cannot quietly do the destructive step, because the step isn't in the list the runner is allowed to execute yet.

This is the test fixture I used for most of the build. It is public in the repo at `skills/my-skills/graph-ask-demo/SKILL.md`:

```yaml
together probes:
  cpu: mcp-monitor.get_cpu_info {}
  mem: mcp-monitor.get_memory_info {}
then:
  need: 1 of 2
  brief: write one short line about the machine from {cpu, mem}
ask me:
  save this reading?
```

Two probes run in parallel, a join needs one of the two to succeed, a model writes a line, and then the run asks. The important part is what that compiles to. The steps and the question go to different places:

```json
{
  "initial_steps": [
    {"id": "cpu", "server": "mcp-monitor", "tool": "get_cpu_info", "parallel_group": "probes", "on_fail": "skip"},
    {"id": "mem", "server": "mcp-monitor", "tool": "get_memory_info", "parallel_group": "probes", "on_fail": "skip"},
    {"id": "join_probes", "kind": "join", "in": ["cpu", "mem"], "min_success": 1},
    {"id": "brief", "depends_on": ["join_probes"], "prompt": "write one short line about the machine from {cpu, mem}"}
  ],
  "stopping_points": [
    {"id": 1, "type": "menu", "title": "save this reading?",
     "options": {"1": {"label": "Yes", "steps": []}, "2": {"label": "No", "steps": []}}}
  ]
}
```

What happens after "Yes" lives inside the option, not in the main step list. The runner can't reach it without an answer.

![Infographic: same brain, it can also act. Skills, MCP tools, automations and workflows with approvals.](https://blog.vodou.ai/img/infographics/vodou-acts-on-your-stack.jpg)

**Diagram: Where the gate actually lives**

A skill compiles into steps and stopping points; the driver runs the steps, parks the run at the stopping point, and only the chosen option's steps run after a person answers

```text
  [Markdown skill] --> [Compiler] --> [Workflow driver] --step list ends--> [Run parked (fixed)] --> [Person picks 1 or 2] --answer--> [Chosen option's steps (fixed)]

  notes:
    Compiler: steps and stopping_points split
    Workflow driver: runs initial steps only
    Run parked: question held, clock running

  The side-effecting branch isn't reachable from the main step list.
```

## The [Run once] button sent the string "run it" and hoped

The first version of "run this plan now" on the plan card posted the chat message `run it` and relied on the model to do the right thing. It usually did. I wanted "usually" gone.

The obvious fix was also wrong: compile the recipe and hand the steps straight to the step executor. That skips the gate completely. The approval lives in `stopping_points`, and a bare step executor never reads that field. It would have been worse than the chat shim, because the shim at least had a model that might read "ask me" and pause.

So `POST /api/graph/run` compiles the recipe, registers it as a live workflow, and runs it through the same driver a saved skill uses, in `MCP-servers/Vodou-Console/src/workflow-driver.ts`. Nothing is written to disk as a skill. The run record is written, because a run that happened is a run that happened whether or not anyone saved the recipe. I verified it live: an ad-hoc run executed its fan and parked at "save this reading to memory?", with the run row reading `ad-hoc | parked`.

**Diagram (beforeafter)**

Before, Run once sent a chat string and called steps directly with no gate; after, it registers a workflow and runs through the same driver that enforces stopping points

```text
  BEFORE: Before
    - button posts "run it" to chat
    - model decides whether to pause
    - direct executeSteps would skip stopping_points

  AFTER: After
    - POST /api/graph/run compiles the recipe
    - registers it as a live workflow
    - same driver as a saved skill
    - run parks at the ask
```

## A run waiting on a person was already marked `complete`

This was the one that took the longest to see. My run outcomes were `running`, `complete`, `partial`, `blocked`, `failed` and `cancelled`. None of them meant "the steps are done and the work isn't." The executor closes a run when the step list ends. The `ask me:` menu gets shown afterward by a layer above it. So every run waiting on a human was recorded as `complete`.

Two guards, each correct, then made the question impossible to answer. The ask lookup only matched live runs. The pending-ask reader refused to give a question to a finished run, and I had added that guard the same morning after it stopped nine dead runs from being answerable. Neither guard was wrong. The state they needed didn't exist.

I added `parked`. Recording the ask and parking the run now happen in the same call, because two writes that have to agree will eventually drift. The outcome the run parked *from* is stored on the ask itself, so answering restores it. A phase whose branches partly failed comes back `partial`, not `complete`.

Two more bugs came out of the same area. When I made finished runs collapse to one line, the collapse ran just before the ask was announced, so a parked run hid its own question and waited forever. The ask is now exempt from collapsing, and announcing one reopens the card. And a four-phase skill wrote four run rows for one thing a person did once, because every menu answer started a new run id. I grouped them by passing the parent explicitly from the code answering the menu. It reads the group *before* answering, because answering clears the ask that identifies it. A time window would have had to guess how long someone takes to press a button.

**Diagram (timeline)**

Build timeline from August 25 to September 2: parked state, phase grouping, run-once through the driver, the side effect found before the gate, sibling-erasure fix, and a clock on parked menus

```text
  Aug 25  ->  runs waiting on a person read complete; add parked (problem)
  Aug 25  ->  four rows for one invocation; group phases by explicit parent
  Aug 25  ->  Run once goes through the driver (fixed)
  Aug 25  ->  memory_store ran before the gate asked (problem)
  Aug 27  ->  one branch's transport failure erased its siblings (problem)
  Sep 2   ->  a parked menu gets a clock and a way out (fixed)
```

## `memory_store` ran, then the run parked to ask permission for it

The live test that proved [Run once] worked also found the worst bug of the lot. The gate stopped the workflow correctly. But a side-effecting step, `Vodou-Recall.memory_store`, sat in an earlier parallel block. It executed as part of the fan, and then the run parked to ask whether it should save. The question was real and the answer no longer mattered.

A stopping point only protects the steps after it. Parallel fans make this easy to miss, because authors think of the block as "gathering" when one branch of it writes.

The fan had its own problems that week. A transport failure in one branch erased the results of its siblings, and a prose step inside a `together:` block was dropped before the fan ever ran. CI was also running every test twice. Each of these makes a run's record disagree with what actually happened, and a gate can only be as good as that record.

## Invariant: every side effect in a run starts after the approval that governs it, and a run holding a question is never terminal

Both halves can be checked against a real codebase. The first says no code path executes compiled steps without going through the component that reads the stopping points, and no side-effecting tool is ordered ahead of its gate. The second says a run with an unanswered question can't have a terminal status. If either is false, you have runs that did things nobody approved, or questions nobody can answer.

## Query your own run table for approvals that can't be answered

This takes five minutes on any stack that logs runs, tool calls and approvals. Adjust the names to your schema.

First, questions stuck on finished runs:

```sql
SELECT r.id, r.status, a.prompt, a.created_at
FROM runs r
JOIN approvals a ON a.run_id = r.id
WHERE a.answered_at IS NULL
  AND r.status IN ('complete', 'failed', 'cancelled');
```

Passing is zero rows. Any row is a person looking at a question your system will refuse to accept an answer to.

Second, side effects that started before their gate was answered:

```sql
SELECT t.run_id, t.tool_name, t.started_at, a.answered_at
FROM tool_calls t
JOIN approvals a ON a.run_id = t.run_id
WHERE t.is_side_effecting = 1
  AND (a.answered_at IS NULL OR t.started_at < a.answered_at);
```

Passing is zero rows. A row like `memory_store | 14:02:11 | NULL` is my bug from above. If you have no `is_side_effecting` flag, you've learned the first thing to add.

Third, find every caller of your step executor:

```bash
grep -rnE "executeSteps|run_steps|execute_plan" src/ | grep -v -i driver
```

Every hit is a path that can run steps without reading stopping points. My [Run once] shim would have been one.

## Skill-compilation research enforces order and still has no word for waiting

The closest academic work is [SIGIL](https://arxiv.org/abs/2607.27309), which starts from the same observation: skills are natural-language specs interpreted at runtime, so "required tool calls, ordering constraints, and checks may be skipped even when explicitly prescribed." Their answer is to compile skills into typed harnesses, which matches what I did. AWS's post on [controlled tool orchestration in MCP servers](https://aws.amazon.com/blogs/devops/flexibility-to-framework-building-mcp-servers-with-controlled-tool-orchestration/) enforces order inside the server. The [Ginger Labs guide](https://gingerlabs.ai/blog/multi-agent-workflows-with-mcp) states the key point plainly: MCP "is not an orchestration engine" and doesn't manage workflow state.

What I didn't find anywhere is the waiting state. Ordering guarantees say B runs after A. They don't say what the run is while a person decides about B, and that's where both of my guards broke. Cloudflare's agents SDK has an [open issue](https://github.com/cloudflare/agents/issues/1652) with the same underlying problem: several recovery paths answer "did this turn finish?" from different artifacts instead of one authoritative record. `parked` is my one record for "not finished, waiting on you."

## A fan branch can still write before the question that governs it

The executor enforces the gate. It doesn't yet stop an author from putting a write in a block ahead of it. The `memory_store` case is written up as a known issue. Right now the protection is the recipe's shape, not a check on it. A compiler that refused a side-effecting tool upstream of an `ask me:` would close it. That compiler check doesn't exist yet.

If you want workflows you teach once in markdown, run by name, and that can't get past a stopping point without your answer, that's what skills do in [vodou.ai](https://vodou.ai/register?utm_source=blog&utm_medium=capability&utm_campaign=skills).

---

Source: [An approval gate your executor doesn't enforce is only a suggestion](https://blog.vodou.ai/agent-approval-gates-executor-enforced/) by Chad Priest, from Building Vodou in Public.
