Guardrails: moves your agent can't make

Stop your coding agent shipping on a red suite or approving its own PR. Earned-move gates, expression and evidence checks, and human-only moves -- declared in YAML, enforced before any command runs.

Your agent shouldn’t be able to approve its own changes or deploy without a green test run – here’s how to make those moves impossible, not just discouraged.

If you run Claude Code or Cursor against real commands – git, npm test, deploys, migrations – you’ve watched it skip the tests, push on a red build, or merge the PR it just opened. The system prompt told it not to. It did it anyway.

A guardrail in praxec isn’t a warning the agent can talk itself past. It’s a precondition on a move (a transition). The move with consequences – ship, deploy, merge – doesn’t become reachable until the gate is passed, and the runtime enforces that before any command (the executor – the thing the move actually runs) fires. You declare it in YAML. No glue code, no per-tool wrappers.

How airtight that is depends on how you run praxec – see How to run it for the three modes and exactly what each enforces.

The earned-move flip

Everything on this page is one idea: a move is not offered until it’s earned. The canonical shape is the ship_guard from the introduction – ship exists only inside checked, and the only way into checked is to run the check. Guards are how you put a condition on that flip: not just “you reached this state,” but “you reached it and the tests actually passed.”

TDD is one instance (no write-implementation move until a test fails). Deploy-gating is another (no deploy move until the suite is green). Same machine, your labels. The rest of this page is the guard kinds you bolt onto those gates.

Guards: the gating mechanism

Guards are preconditions on a declared transition. They run after input schema validation but before the executor. If any guard fails, the move is rejected and the response comes back with the current legal moves attached, so the agent recovers on its own.

Note the two distinct refusals, because they answer different questions:

states:
  checked:
    transitions:
      deploy:
        target: deployed
        guards:
          - kind: expr
            expr: "$.context.testsPassed == true"
        executor: { kind: cli, connection: shell, args: [./deploy.sh] }

That expr guard means: if the tests haven’t passed, the deploy never runs. The agent gets GUARD_REJECTED and links to whatever it can do instead – typically, go run the tests.

Guards run in declaration order. First failure stops the chain. Each evaluation emits a guard.evaluated audit event, so you always know what passed and what didn’t.

Throughout, context is the workflow’s shared state – the running bag of facts each step reads and writes (you may have seen it called a “blackboard” or “slots” elsewhere; here it’s just context). A guard like $.context.testsPassed == true reads from it.

The guard kinds you’ll actually use

For a single engineer running an agent locally, two guard kinds carry the weight: expr (a condition on the shared context) and evidence (proof a step really happened). Start there. The other two – permission and role – only matter if multiple people share one gateway; they’re at the bottom.

expr – a condition on the shared context

Evaluates a binary predicate against the workflow’s state. This is the guard you’ll reach for most: “tests passed,” “coverage is high enough,” “the branch is a feature branch.”

guards:
  - kind: expr
    expr: "$.context.testsPassed == true"

The expression has access to three scopes:

Scope Reads from
$.arguments.* The caller’s transition arguments
$.context.* The workflow’s accumulated shared state
$.workflow.input.* The input passed to praxec.command({definitionId}) (start)

Operators: ==, != for any same-typed values. <, <=, >, >= for numbers. starts_with, contains for strings.

Operand types: paths, numbers, quoted strings ("foo"), booleans (true/false), and null.

Path-to-path comparisons work: "$.context.after > $.context.before" is valid.

Array access: use bracket notation items[0].name or dot notation items.0.name.

Some examples, all from a coding loop:

# Tests must have passed
- kind: expr
  expr: "$.context.testsPassed == true"

# Coverage threshold before merge
- kind: expr
  expr: "$.context.coverage >= 80"

# Only ship feature branches through this path
- kind: expr
  expr: "$.arguments.branch starts_with 'feat/'"

# Enough reviews collected
- kind: expr
  expr: "$.context.reviewCount >= $.context.requiredReviews"

evidence – proof a step actually happened

expr trusts a flag in context. evidence is stronger: it requires proof a step really ran – e.g. the test executor actually executed and passed – and the agent can’t fake it. Evidence records come from successful executor results: the CLI executor records cli_output on every success, the human executor records human_request, and custom executors emit their own kinds. The agent can’t write an evidence record by asserting it in context; only a real executor run produces one.

guards:
  - kind: evidence
    requires:
      - tests_passed
      - acceptance_criteria_met

Every listed kind must have at least one evidence record for this workflow before the move fires. You can also require a specific count for quorum-style sign-off:

guards:
  - kind: evidence
    requires:
      - { kind: human_request, count: 2 }

That means two separate human approvals must exist before this move can fire – useful for high-risk changes (a prod migration) that need more than one person.

permission and role – only if you run multi-tenant

These check the caller’s identity, so they only do anything when several humans share one gateway with different identities. In a single-user local setup they reject everyone – the bundled binary treats callers as anonymous, so a permission guard always fails. If that’s you, skip these and gate on expr, evidence, or a human-only move (below).

# permission -- caller must hold a named permission
guards:
  - kind: permission
    permission: deploy.production

# role -- caller must have a named role
guards:
  - kind: role
    role: approver

Roles are broad categories (“approver”, “admin”); permissions are specific actions (“deploy.production”). Reach for them when you’re running praxec as a shared gateway for a team, not a laptop.

Composing guards with combinators

Guards can be combined using all_of, any_of, and not:

guards:
  # Both must pass: tests green AND coverage high enough
  - kind: all_of
    guards:
      - { kind: expr, expr: "$.context.testsPassed == true" }
      - { kind: expr, expr: "$.context.coverage >= 80" }

  # Either one is sufficient: a real test run, or a recorded override
  - kind: any_of
    guards:
      - { kind: evidence, requires: [tests_passed] }
      - { kind: evidence, requires: [override_approved] }

  # Negation: must NOT be flagged as blocked
  - kind: not
    guard: { kind: expr, expr: "$.context.blocked == true" }

You can nest these arbitrarily. An allOf inside an anyOf inside another allOf works fine. But if you find yourself nesting three levels deep, consider whether the workflow should have more states instead.

The move your agent can’t make: human-only

This is the one that stops the agent approving its own PR. The actor field on a transition controls who can submit it. Four values:

Actor Who can submit
agent The LLM (default)
human A human – the runtime rejects agents with ACTOR_MISMATCH, and the move is never even offered to the agent
system Internal system calls
deterministic The runtime itself – auto-executes without waiting for anyone

The one that matters here is human:

transitions:
  merge:
    actor: human
    target: merged
    guards:
      - kind: evidence
        requires: [tests_passed]

When a move is marked actor: human, the agent literally cannot submit it – and the link is never handed to it in the first place. If it reaches for the move anyway, the runtime checks Principal::is_human() on the submitter and rejects with ACTOR_MISMATCH before the executor runs or the workflow advances. This isn’t a guard the agent can talk past – it’s a hard gate in the runtime. The agent can open the PR; a person merges it. (Whether the agent can sidestep praxec entirely depends on your run mode – see How to run it.)

This is defense in depth. You might stack three independent layers on the same move:

The human executor

The human executor doesn’t execute anything. It records an approval request, emits a human.approval.requested audit event, and returns a pending status. The workflow stays in its current state until a human resolves the request.

transitions:
  request_approval:
    target: awaiting_approval
    executor:
      kind: human
      queue: engineering-approvals

The queue field is a label – it tells your approval integration (Slack bot, Linear sync, CLI watcher) which queue this request belongs to. The gateway doesn’t manage queues itself; it just records the event.

A human resolves the request through your approval tooling, which calls back into the gateway to advance the workflow.

A complete guarded workflow: deploy with human approval

Here’s the kind of flow you’d put in front of a coding agent: it can run tests, build, and propose a production migration, but a human approves before anything touches prod. It combines all the guard kinds, and every gate is a coding move the agent might otherwise make on its own.

workflows:
  deploy_with_approval:
    description: Run tests, propose a prod migration, require human approval before it runs.
    tags: [deploy, ci, guarded]

    inputSchema:
      type: object
      required: [service, migration]
      properties:
        service:   { type: string }
        migration: { type: string }

    initialState: building
    initialContext:
      testsPassed: null
      coverage: null

    states:
      building:
        onEnter:
          executor:
            kind: cli
            connection: shell
            args: [npm, test, --, --coverage]
          output:
            testsPassed: "$.output.passed"
            coverage: "$.output.coveragePct"

        linkFilter: byGuards
        transitions:
          to_review:
            title: Tests green -- send the migration for review
            target: awaiting_approval
            guards:
              - { kind: expr, expr: "$.context.testsPassed == true" }
              - { kind: expr, expr: "$.context.coverage >= 80" }

          tests_failed:
            title: Tests red -- back to fixing
            target: failed
            guards:
              - { kind: expr, expr: "$.context.testsPassed == false" }

      awaiting_approval:
        onEnter:
          executor:
            kind: human
            queue: deploy-approvals
        transitions:
          approve:
            title: Approve the migration for prod
            actor: human
            target: deploying
            guards:
              - kind: evidence
                requires: [tests_passed]

          reject:
            title: Reject the migration
            actor: human
            target: failed

      deploying:
        onEnter:
          executor:
            kind: cli
            connection: shell
            args: [./run-migration.sh]
          reliability:
            retry:
              maxAttempts: 3
              backoff: exponential
              initialDelayMs: 1000
        transitions:
          mark_done:
            target: deployed

      deployed:
        terminal: true

      failed:
        terminal: true

Walk through what’s happening:

  1. building – npm test --coverage runs on entry (onEnter) and writes the result into the shared context. expr guards route on it: tests green and coverage at least 80 sends the migration to review; tests red sends it back. linkFilter: byGuards means the agent only sees the move that actually applies – it can’t pick the green path on a red build.

  2. awaiting_approval – the human executor records an approval request and parks the workflow. approve is actor: human (the agent literally can’t fire it – no self-approval) and carries an evidence guard requiring proof the tests really ran (tests_passed), so a stale testsPassed: true flag alone won’t unlock it.

  3. deploying – only reachable after a human approves. The CLI executor runs the migration with retry and exponential backoff.

  4. Terminal states – deployed and failed have no outgoing moves. The workflow is done.

By default, every workflow response includes links for all transitions from the current state. The model picks one, and if the guards reject it, the error response includes the legal links for recovery.

If you’d rather the model only sees transitions it can actually take right now, set linkFilter: byGuards:

workflows:
  demo:
    linkFilter: byGuards    # workflow-wide default
    states:
      triaged:
        linkFilter: byGuards  # or per-state (overrides the workflow setting)

When set, the runtime evaluates each transition’s guards silently against the current context and principal, and only returns links for transitions that would pass. This is useful when guards are mutually exclusive – the model doesn’t have to guess which one applies.

One caveat: guards that depend on $.arguments.* can’t be evaluated at link-generation time (no arguments exist yet), so those transitions typically filter out. Use linkFilter: byGuards when your guards depend on $.context.* and $.workflow.input.*.