Skip to content

Check an agent's plan

An agent that can act (pay, approve, file, ship) should not answer "done" in prose. It should answer with a plan: a sequence of operations over your model. GND checks that plan before anything runs. It verifies each step's preconditions, every intermediate state, and the frame: no fact may change unless a declared operation changes it.

This page is the story told by the animation on the home page, and every command below runs. The model, the state and both plans ship with GND. The plans stand in for what an agent emitted; no model provider is called.

The setup

from importlib.resources import files
from shutil import copytree
copytree(str(files("gnd") / "examples" / "approvals"), "approvals")

approvals/model.orm.yaml declares three operations. Each one says what must hold before it runs and what it changes:

Operation Precondition Effect
approve_expense(expense, approver) the expense is a draft (pre_draft, alethic) status becomes approved; the approver is recorded
submit_expense_to_workday(expense, submission) the expense is approved (pre_approved, alethic) a Workday submission is created; status becomes submitted
reimburse_expense(expense, submission, approver, amount, on_date) Workday has it (pre_submitted, alethic); an approver is on file (pre_has_approver, deontic) the reimbursement is recorded

In approvals/state.pop.yaml, expense 41 (Ada's €90 team dinner) is still a draft.

gnd validate approvals/model.orm.yaml

The agent's first plan

The user asks: "Reimburse Ada's €90 team dinner, expense 41." The agent answers with plan-draft-1.steps.json, which pays the expense directly:

{"op": "reimburse_expense",
 "bindings": {"expense": 41, "submission": "WD-8041", "approver": "alice",
              "amount": 90, "on_date": "2026-10-05"}}

Check it in plan mode. Without --post, GND computes the resulting state itself:

gnd check approvals/model.orm.yaml --pre approvals/state.pop.yaml --steps approvals/plan-draft-1.steps.json

The check exits 1. Step 1 reports:

[fail] $.steps[1].preconditions[id=pre_submitted]: step 1 (reimburse_expense) requires
       expense_submitted_to_workday to hold for {'expense': 41, 'submission': 'WD-8041'}, and it does not
[warning] $.steps[1].preconditions[id=pre_has_approver]: step 1 (reimburse_expense) requires
       expense_approved_by to hold for {'expense': 41, 'approver': 'alice'}, and it does not

The alethic precondition failed, so the plan is rejected. The deontic one is reported as a warning. That is the feedback the agent receives: the rule, the operation, the step and the exact binding. The feedback does not contain the answer.

The revised plan

With that feedback the agent adds the missing steps: approve, submit to Workday, then reimburse. That is plan-draft-2.steps.json:

gnd check approvals/model.orm.yaml --pre approvals/state.pop.yaml --steps approvals/plan-draft-2.steps.json

Exit 0. GND walked all three steps, found every precondition satisfied, and printed the computed post-state, in which expense 41 is reimbursed. Only now does your application execute the plan.

plan-draft-2.canonical.steps.json is the same plan pinned with the model's canonical identity (gnd validate approvals/model.orm.yaml --identity) instead of the legacy model_digest; gnd check accepts either identity and also exits 0. A Pyrel rules program generated from the model checks plans against the legacy value only, so pin plans meant for it with the legacy model_digest.

The same check from Python returns the report as a dictionary:

from gnd import api

first = api.transition("approvals/model.orm.yaml", "approvals/state.pop.yaml",
                       "approvals/plan-draft-1.steps.json")
second = api.transition("approvals/model.orm.yaml", "approvals/state.pop.yaml",
                        "approvals/plan-draft-2.steps.json")
print(first["exit_code"], second["exit_code"])  # 1 0
assert (first["exit_code"], second["exit_code"]) == (1, 0)

A side effect nobody declared

Plans are not the only risk. Suppose an agent, or the application code it wrote, approves expense 41 and reports the resulting state. In claimed-post.pop.yaml, bystander expense 42 has also flipped from submitted to rejected. Every fact in that state is individually legal: one status per expense, valid values, nothing missing. No snapshot check can see the problem.

Audit the walk by supplying the claimed post-state with --post:

gnd check approvals/model.orm.yaml --pre approvals/state.pop.yaml --steps approvals/approve-41.steps.json --post approvals/claimed-post.pop.yaml

The frame check fails it:

FRAME
  expense_has_status: alethic (all-alethic; approve_expense)
  [fail] $.post.facts.expense_has_status: the declared post-state contains 1 expense_has_status
         fact(s) that no operation in this walk asserts
  [fail] $.post.facts.expense_has_status: the declared post-state is missing 1 expense_has_status
         fact(s) that no operation in this walk retracts

approve_expense declares exactly what it changes, so a change to expense 42 is an undeclared side effect. Frame checks cover the fact types the walk touches; audit changes outside that set separately.

Ask your agent for plans

Have your application's skill, or your prompt, require structured plans:

When asked to act on expenses, do not act directly. Write a steps.json plan using only
the operations in model.orm.yaml, run
  gnd check model.orm.yaml --pre state.pop.yaml --steps plan.steps.json --format json
and revise the plan until it exits 0. Report the final plan and the check's exit code.

To run that loop with a bounded number of attempts and closed feedback, use a development session with a transition-walk contract. The data and transitions guide covers the walk format, plan mode and audit mode in detail.