Skip to content

Build a course planner that can't skip a prerequisite

You'll take a registrar's rules, written in plain English, and see Claude Code turn them into a model. Then a bot plans a student's semester, and GND checks every step of the plan before anything is enrolled. The bot in the replays is a small model, so you'll see it get the plan wrong, read GND's feedback and fix it. Every replay on this page is a recording of a real run, not a mockup. The runs you can try yourself use a scripted stand-in bot, and say so.

Files on GitHub

What you'll build

A folder with the registrar's rules, a model of them, and a bot that answers "plan this student's term" with a list of enroll steps.

Who Does what
You write the rules, ask questions, approve changes
Claude Code with the GND skills turns the rules into a model
The bot (any model behind a command) proposes plans
GND checks each plan and names the rule that fails and the facts that break it

Before you start. Install GND from PyPI as the installation page shows, or, to use unreleased work on main, from a checkout of the repository as below. After the export line, python and gnd mean that environment:

git clone https://github.com/vasiloglou/gnd-public.git
uv venv gnd-env --python 3.13
uv pip install --python gnd-env/bin/python ./gnd-public
export PATH="$PWD/gnd-env/bin:$PATH"

For the Claude Code steps, also install the GND plugin, as the plugin page shows. Then get the example folder:

python -c "import gnd, pathlib, shutil; shutil.copytree(pathlib.Path(gnd.__file__).parent / 'examples/plan_apps', 'plan_apps', ignore=shutil.ignore_patterns('recordings', 'skill-sessions', '__pycache__'), dirs_exist_ok=True)"
ls plan_apps/course
README.md
app.py
credit-cap-proposal.json
model.orm.json
plans
rules.md
stand_in_bot.py
state.pop.json

app.py is not a GND command. It is this example's own small program, built on GND: the planning bot's front end, the way your own app would be. It has four commands:

Command What it does
python plan_apps/course/app.py ask "QUESTION" asks the bot for a plan, checks it with GND, sends any refusal back, and saves the accepted result
python plan_apps/course/app.py check PLAN checks one plan with GND
python plan_apps/course/app.py execute PLAN --out FILE checks a plan and, if GND accepts it, writes the new state
python plan_apps/course/app.py rules prints the model's rules as PyRel (optional; see step 3)

GND's own commands start with gnd, such as gnd validate and gnd check. The app calls GND through its Python API; the shared code is in plan_apps/core.py. To build an app like this for your own model, /gnd:gnd-codegen generates a project from the model.

1. Write the rules

Start with what the registrar already knows, written the way they'd say it.

You get rules.md:

# Registrar rules

These are the rules as the registrar would say them. `/gnd:gnd-model-from-text`
turns this file into `model.orm.json`.

- A student can't take a course before passing every course it needs,
  directly or through a chain of prerequisites.
- A student doesn't enroll in a course they have already passed.
- A student takes at most 6 courses in a term.
- A student takes at most 18 credits in a term.
- A student has one grade per course, and once a grade is recorded it never
  changes.
- Only courses offered that term can be taken.

## What the registrar does

- Enroll a student in a course for a term.
- Drop a course, or every course in a term.
- Record a grade, which also marks the course as passed.
- Admit a new student.

## The spring catalogue

| Course | Credits | Needs | Offered |
|---|---|---|---|
| CS101 Intro to Programming | 4 | | spring |
| MATH120 Calculus | 4 | | |
| MATH220 Linear Algebra | 4 | MATH120 | spring |
| STAT210 Probability | 3 | MATH120 | spring |
| CS230 Data Structures | 4 | CS101 | spring |
| CS250 Databases | 3 | CS101 | fall |
| CS340 Machine Learning | 4 | CS230, MATH220, STAT210 | spring |
| PHIL101 Logic | 3 | | spring |

Maya has passed CS101, MATH120 and CS230.

2. Model them

The gnd-model-from-text skill turns the file into an ORM model: the facts the app talks about and the rules they must obey. It reads each rule, asks when a rule could mean two things, and runs gnd validate before it hands the model over.

You type

/gnd:gnd-model-from-text rules.md

Claude Code does

Claude Code (Haiku) models the registrar's rules (recorded run 1) A replay of a recorded session; the full text is in the transcript below the figure. Claude Code (Haiku) models the registrar's rules (recorded run 1) you: /gnd:gnd-model-from-text rules.md Bash pwd && ls -la && ls -la <tmp> 2>/dev/null | head -50 Glob **/rules.md Read <workspace>/rules.md → 1 # Registrar rules Bash cd <repo>/src/gnd/skills && ls gnd-model-from-text gnd-model-from-text/references gnd- orm-core gnd-orm-core/references && wc -l gnd-orm-core/references/*.md &&… → refused by Claude Code's permission rules … reads the skill's reference pages (1 call; all in the transcript) Bash cd <repo>/src/gnd/skills && cat gnd-model-from-text/examples/movie_catalog.orm.yaml; echo ======; cat gnd-orm-core/references/provenance-and-modality.md → returned an error (its output is not kept) … reads the skill's reference pages (10 calls; all in the transcript) Bash cd <repo>/src/gnd/skills/gnd-orm-core/references && grep -n -i "aggregat\|recurs\|sum(" representation-format.md | head -40; echo ----; grep -n -A4 '"dialogue_… → refused by Claude Code's permission rules … reads the skill's reference pages (5 calls; all in the transcript) Bash cd <repo>/src/gnd/skills/gnd-orm-core/references && grep -n "^| *\`\?ring\|id: ring\|ring-" constraint-inference.md | head -20; gnd --help 2>&1 | head -40 → refused by Claude Code's permission rules … reads the skill's reference pages (1 call; all in the transcript) Bash gnd --help → usage: gnd [-h] [--version] claude: Ground truth is clear now: the input is a rich, self-contained rules document, so I'm running One-shot mode (no dialogue to wait on), with explicit rules as confirmed/alethic and inferred pieces left `proposed`/`deontic`. Writing the model. Write <workspace>/model.orm.yaml → File created successfully at: <workspace>/model.orm.yaml Bash gnd validate model.orm.yaml; echo "EXIT=$?" → refused by Claude Code's permission rules Bash gnd validate model.orm.yaml → Exit code 3: ERROR at $.fact_types[id=student_course_grade].reading: reading placeholder '{Grade}' at position 1 does not name role 1's object type 'course' [semantic:reading- placeholders] Edit <workspace>/model.orm.yaml → The file <workspace>/model.orm.yaml has been updated successfully. Edit <workspace>/model.orm.yaml → The file <workspace>/model.orm.yaml has been updated successfully. Bash gnd validate model.orm.yaml → OK: model.orm.yaml is a valid GND ORM model claude: Validation passes. Now the verbalization for stakeholder review. Write <workspace>/model.verbalization.txt → File created successfully at: <workspace>/model.verbalization.txt Edit <workspace>/model.verbalization.txt → The file <workspace>/model.verbalization.txt has been updated successfully. Edit <workspace>/model.verbalization.txt → The file <workspace>/model.verbalization.txt has been updated successfully. claude: The model is written and `gnd validate` passes on both layers (the full validator, not the fallback). I did not run a population check, so the guards and derived rules are structurally valid but not yet exercised against data. **Files** - `<workspace>/model.orm.yaml` - `<workspace>/model.verbalizat… (turn 1 ends)
Transcript of the replay (condensed: long messages are cut and each tool result shows its first line)
you: /gnd:gnd-model-from-text rules.md
  Bash pwd && ls -la && ls -la <tmp> 2>/dev/null | head -50
  Glob **/rules.md
  Read <workspace>/rules.md
    → 1 # Registrar rules
  Bash cd <repo>/src/gnd/skills && ls gnd-model-from-text gnd-model-from-text/references gnd-orm-core gnd-orm-core/references && wc -l gnd-orm-core/references/*.md &&…
    → refused by Claude Code's permission rules
  Bash ls <repo>/src/gnd/skills/gnd-model-from-text <repo>/src/gnd/skills/gnd-model-from-text/references <repo>/src/gnd/skills/gnd-orm-core/references; wc -l <repo>/s…
  Bash cd <repo>/src/gnd/skills && cat gnd-model-from-text/examples/movie_catalog.orm.yaml; echo ======; cat gnd-orm-core/references/provenance-and-modality.md
    → returned an error (its output is not kept)
  Read <repo>/src/gnd/skills/gnd-orm-core/references/provenance-and-modality.md
  Grep ^#+ |^\| `?type`? |type: (uniqueness|mandatory|value|frequency|subset|equality|exclusion|exclusive_or|ring|subtype|textual|external_uniqueness|role_value|set_c…
  Read <repo>/src/gnd/skills/gnd-orm-core/references/constraint-reference.md
  Read <repo>/src/gnd/skills/gnd-orm-core/references/constraint-reference.md
  Read <repo>/src/gnd/skills/gnd-orm-core/references/constraint-reference.md
  Read <repo>/src/gnd/skills/gnd-orm-core/references/constraint-reference.md
  Grep ^#{1,4}
  Read <repo>/src/gnd/skills/gnd-orm-core/references/representation-format.md
  Read <repo>/src/gnd/skills/gnd-orm-core/references/representation-format.md
  Read <repo>/src/gnd/skills/gnd-orm-core/references/representation-format.md
  Bash cd <repo>/src/gnd/skills/gnd-orm-core/references && grep -n -i "aggregat\|recurs\|sum(" representation-format.md | head -40; echo ----; grep -n -A4 '"dialogue_…
    → refused by Claude Code's permission rules
  Grep aggregat|recurs|sum\(
  Grep dialogue_turn|csdp_step
  Read <repo>/src/gnd/skills/gnd-orm-core/references/representation-format.md
  Read <repo>/src/gnd/skills/gnd-orm-core/references/representation-format.md
  Read <repo>/src/gnd/skills/gnd-orm-core/references/representation-format.md
  Bash cd <repo>/src/gnd/skills/gnd-orm-core/references && grep -n "^| *\`\?ring\|id: ring\|ring-" constraint-inference.md | head -20; gnd --help 2>&1 | head -40
    → refused by Claude Code's permission rules
  Grep ring-[a-z-]+
  Bash gnd --help
    → usage: gnd [-h] [--version]
claude: Ground truth is clear now: the input is a rich, self-contained rules document, so I'm running One-shot mode (no dialogue to wait on), with explicit rules as confirmed/alethic and inferred pieces left `proposed`/`deontic`. Writing the model.
  Write <workspace>/model.orm.yaml
    → File created successfully at: <workspace>/model.orm.yaml
  Bash gnd validate model.orm.yaml; echo "EXIT=$?"
    → refused by Claude Code's permission rules
  Bash gnd validate model.orm.yaml
    → Exit code 3: ERROR at $.fact_types[id=student_course_grade].reading: reading placeholder '{Grade}' at position 1 does not name role 1's object type 'course' [semantic:reading-placeholders]
  Edit <workspace>/model.orm.yaml
    → The file <workspace>/model.orm.yaml has been updated successfully.
  Edit <workspace>/model.orm.yaml
    → The file <workspace>/model.orm.yaml has been updated successfully.
  Bash gnd validate model.orm.yaml
    → OK: model.orm.yaml is a valid GND ORM model
claude: Validation passes. Now the verbalization for stakeholder review.
  Write <workspace>/model.verbalization.txt
    → File created successfully at: <workspace>/model.verbalization.txt
  Edit <workspace>/model.verbalization.txt
    → The file <workspace>/model.verbalization.txt has been updated successfully.
  Edit <workspace>/model.verbalization.txt
    → The file <workspace>/model.verbalization.txt has been updated successfully.
claude: The model is written and `gnd validate` passes on both layers (the full validator, not the fallback). I did not run a population check, so the guards and derived rules are structurally valid but not yet exercised against data. **Files** - `<workspace>/model.orm.yaml` - `<workspace>/model.verbalizat…
  (turn 1 ends)

This is a recorded session with Claude Haiku, a small, cheap model, on purpose: the point of GND is that a weaker model can still build a correct app, because every mistake it makes is caught and named. The full session is in the repository.

What went wrong, and how it was caught

The replay shows two kinds of failure. Only the second is a modelling mistake.

Refused shell commands. The recording let the assistant run gnd commands and simple read-only commands, nothing more. Four times Haiku chained commands with &&, ; or |, for example cd … && ls … && which gnd, and Claude Code refused each one because it could not check the whole chain. One of them was gnd validate model.orm.yaml; echo "EXIT=$?".

Each time, Haiku read the files one at a time instead, or ran gnd validate on its own. A fifth combined read returned an error; the recording keeps the command but not why. None of this touched the model.

A modelling mistake that gnd validate caught. In two fact types, Haiku wrote the sentence with its placeholders in a different order from the roles:

Fact type Roles, in order Haiku's first reading After the fix
student_course_grade student, course, grade {Student} got grade {Grade} in {Course} {Student} in {Course} got grade {Grade}
student_credits_in_term student, term, credits {Student} takes {Credits} credits in {Term} {Student} in {Term} takes {Credits} credits

Both first readings sound fine to a person, but GND reads a reading by position: the second placeholder names the second role. With Haiku's first version, every sentence GND writes about a grade would put the course where the grade belongs. gnd validate refused the model and said exactly where:

ERROR at $.fact_types[id=student_course_grade].reading: reading placeholder '{Grade}' at position 1 does not name role 1's object type 'course' [semantic:reading-placeholders]
ERROR at $.fact_types[id=student_credits_in_term].reading: reading placeholder '{Credits}' at position 1 does not name role 1's object type 'term' [semantic:reading-placeholders]

GND did not fix the model; it found the mistake precisely enough that Haiku fixed it in two small edits, reordering each sentence to match its roles. It then ran gnd validate again, which passed. Positions count from 0, so "position 1" is the second placeholder.

In two of the other three recorded sessions, the skill asked questions first and the scripted user answered. In the third, Claude Code's default model also drafted at once. All four ended with a valid model; the session notes list each run.

You get a model whose every rule keeps the registrar's words. Here is the prerequisite rule from the model above:

  # Enrolment requires every prerequisite, direct or through a chain, to be passed.
  - id: enrolment_requires_passed_prerequisites
    type: guarded
    antecedent:
      - { fact_type: student_enrolled_in_course_in_term, bind: { student: "$s", course: "$c", term: "$t" } }
      - { fact_type: course_transitively_needs_course, bind: { course: "$c", prerequisite: "$p" } }
    consequent:
      atom: { fact_type: student_passed_course, bind: { student: "$s", course: "$p" } }
    source: explicit
    status: confirmed
    modality: alethic
    rationale: "A student can't take a course before passing every course it needs, directly or through a chain. Chain is handled by the derived transitive fact type."
    provenance:
      csdp_step: 3
      dialogue_turn: 1
      user_quote: "A student can't take a course before passing every course it needs, directly or through a chain of prerequisites."

  # Enrolment is refused for a course already passed.

GND says the model the example ships is valid:

gnd validate plan_apps/course/model.orm.json
OK: plan_apps/course/model.orm.json is a valid GND ORM model

The rest of this tutorial uses that shipped model, written the same way. Your own session will name things differently; the rules are the same.

3. Optional: read the rules as PyRel

You can skip this step. GND checks every plan with its own checker, and nothing in this tutorial needs PyRel.

PyRel is RelationalAI's Python library. It matters when the facts you want to check live in your company's data platform. You can prototype locally with DuckDB, a small database that runs inside Python. For data in Snowflake, RelationalAI's Native App runs PyRel there; Snowflake execution is not yet supported by gnd export. What PyRel is, and when you need it explains both, and which of them GND has tested.

GND writes the PyRel version of every rule from the model; nobody writes it by hand. The GND command is gnd export pyrel:

gnd export pyrel plan_apps/course/model.orm.json --rules lqp --out course_rules.py
Exported the model's rules as PyRel requirements (lqp version) to course_rules.py

In the printout below, "the LQP program" is the version for a RelationalAI engine and "the DuckDB program" is the one that runs locally. The example app prints the same rules, one per registrar sentence. Exporting and printing need only GND; running them needs PyRel.

You type

python plan_apps/course/app.py rules
The rules as PyRel, generated from the model by `gnd export pyrel`. In a plan, every relation also
takes the state as its first argument: `step` is the state after a step, `before` the state before it.
These are the LQP program's requirements; the DuckDB program checks the same rules as violation queries.

Derived facts
  prerequisite_chain: Course needs Course, directly or through a chain
    m.where(course_needs(step, c, p)).define(prerequisite_chain_derived(step, c, p))
    m.where(course_needs(step, c, x), prerequisite_chain_derived(step, x, p)).define(prerequisite_chain_derived(step, c, p))
  term_credits: Student takes TotalCredits credits in Term
    total = aggregates.sum(c, n).per(step, s, t)   # GND counts each distinct ($c, $n) once
    m.where(enrolls(step, s, c, t), course_credits(step, c, n)).define(term_credits_derived(step, s, total, t))

Rules checked on every state
  term_load: (must) A student takes at most 6 courses in a term.
    m.where(enrolls(step, student, course, term)).require(aggregates.count(course).per(step, student, term) <= 6)
  one_grade: (must) A student has one grade per course.
    m.where(grade_of(step, student, grade_1, course), grade_of(step, student, grade_2, course)).require(grade_1 == grade_2)
  prerequisites_first: (must) A student can't take a course before passing every course it needs, directly or through a chain.
    m.where(enrolls(step, student, course, term), prerequisite_chain(step, course, needs)).require(passed(step, student, needs))
  no_retake: (must) A student doesn't enroll in a course they already passed.
    m.where(enrolls(step, student, course, String.ref())).require(not_(passed(step, student, course)))
  credit_cap: (must) A student takes at most 18 credits in a term.
    m.where(term_credits(step, student, total, term)).require(total <= 18)

Preconditions, checked on the state before each step
  pre_offered: Only courses offered that term can be taken.
    m.where(do_enroll(step, student, course, term), previous(step, before)).require(offered(before, course, term))

Rules across a step, comparing the state before it with the state after
  grade_final: Once a grade is recorded, it never changes.
    m.where(do_change_grade(step, student, course, grade), previous(step, before), grade_of(before, student, old_grade, course), grade_of(step, student, new_grade, course)).require(new_grade == old_grade)

Read one: prerequisites_first says where Maya enrolls in a course that needs another course, directly or through a chain, require that she has passed it. A plan breaks the rule when the where part matches and the require part fails. The matching values are the facts GND names in its feedback.

The registrar said The kind of rule The PyRel requirement
"directly or through a chain of prerequisites" a derived fact, computed recursively prerequisite_chain defines itself from course_needs
"at most 6 courses in a term" a count aggregates.count(course).per(...) <= 6
"at most 18 credits in a term" a sum over a derived fact term_credits sums credits; total <= 18
"Only courses offered that term can be taken" a precondition of enroll checked on the state before the step
"once a grade is recorded it never changes" a rule across a step compares the grade before and after change_grade

These lines come from the program GND generates for a RelationalAI engine. That program is compiled and counted here, not yet run on an engine. On your laptop the same rules run in a second program, as DuckDB queries that look for the facts breaking them. That one checked every recorded plan on this page, and GND's own checker gives each of those plans the same verdict.

4. Ask the bot

You type

Plan Maya's spring semester. She really wants Machine Learning.

The app sends the bot the question, the rules, the operations and Maya's record. The bot answers with a plan, a list of operation steps, never prose. In the recording below, the bot is Llama 3.2 3B on OpenRouter, a small model that is cheap, fast and easy to fool.

5. It proposes a plan

The bot does

Llama 3.2 3B plans Maya's spring semester (recorded run 2) A replay of a recorded session; the full text is in the transcript below the figure. Llama 3.2 3B plans Maya's spring semester (recorded run 2) attempt 1 plan enroll(student='maya', course='CS340', term='spring') pyrel check: exit 1 state 1 prerequisites_first violated {"$course": "CS340", "$needs": "MATH220", "$student": "maya", "$term": "spring"} the rule in PyRel: m.where(enrolls(step, student, course, term), prerequisite_chain(step, course, needs)).require(passed(step, student, needs)) state 1 prerequisites_first violated {"$course": "CS340", "$needs": "STAT210", "$student": "maya", "$term": "spring"} attempt 2 the bot says: CS340 requires both MATH220 and STAT210, and Maya has not yet passed MATH220. plan enroll(student='maya', course='MATH220', term='spring') pyrel check: exit 0 (accepted) executed: the new state is in <workspace>/next.pop.json
Transcript of the replay
attempt 1
  plan  enroll(student='maya', course='CS340', term='spring')
  pyrel check: exit 1
    state 1  prerequisites_first  violated  {"$course": "CS340", "$needs": "MATH220", "$student": "maya", "$term": "spring"}
      the rule in PyRel: m.where(enrolls(step, student, course, term), prerequisite_chain(step, course, needs)).require(passed(step, student, needs))
    state 1  prerequisites_first  violated  {"$course": "CS340", "$needs": "STAT210", "$student": "maya", "$term": "spring"}
attempt 2
  the bot says: CS340 requires both MATH220 and STAT210, and Maya has not yet passed MATH220.
  plan  enroll(student='maya', course='MATH220', term='spring')
  pyrel check: exit 0 (accepted)
  executed: the new state is in <workspace>/next.pop.json

Its first plan enrolls Maya straight into CS340, Machine Learning.

6. GND checks it

Nothing is enrolled yet. The app applies the plan to a copy of Maya's record and checks every rule on the state after each step.

The checker says the plan is refused at state 1, the state after its only step. The recordings were made with the app's optional PyRel backend, the DuckDB program GND generates. The commands you run below use GND's own checker, which gives the same verdict.

It reports prerequisites_first violated, once for MATH220 and once for STAT210. Each witness (the facts that break the rule) names the student, the course and the prerequisite she has not passed.

The bot gets the rule's name, where it broke and the facts: never the answer. The PyRel line under the failure is printed for you, for reference, whichever checker ran; it is not sent to the bot.

7. Repair and execute

The bot reads the feedback and tries again with MATH220 alone. The checker accepts the plan and the app writes Maya's new record to a fresh file. Nothing was enrolled before the check passed.

The repair is real but thin: the bot left STAT210 out, though Maya needs it too. GND checks the rules, not whether a plan does everything you hoped for.

This was the first course run, in the order of the results table, refused on a rule and then accepted: llama-3.2-3b/course/run-2 in the recorded runs. Claude Haiku and Claude Code's default model planned MATH220 and STAT210 at the first attempt in all five of their runs, each with a note on why CS340 has to wait.

Try it yourself with the scripted stand-in bot, which needs no model provider. It makes the same kind of mistakes on purpose:

python plan_apps/course/app.py ask "Plan Maya's spring semester. She really wants Machine Learning." --out next.pop.json
attempt 1
  plan  enroll(student='maya', course='CS340', term='spring')
  plan  enroll(student='maya', course='MATH220', term='spring')
  plan  enroll(student='maya', course='STAT210', term='spring')
  gnd check: exit 1
    states 1-3  prerequisites_first  violated  [{"$student": "maya", "$course": "CS340", "$term": "spring", "$needs": "MATH220"}, {"$student": "maya", "$course": "CS340", "$term": "spring", "$needs": "STAT210"}]
      the rule in PyRel: m.where(enrolls(step, student, course, term), prerequisite_chain(step, course, needs)).require(passed(step, student, needs))
attempt 2
  plan  enroll(student='maya', course='MATH220', term='spring')
  plan  enroll(student='maya', course='STAT210', term='spring')
  plan  enroll(student='maya', course='CS250', term='spring')
  gnd check: exit 1
    step 3  $.steps[3].preconditions[id=pre_offered]  violated  step 3 (enroll) requires offered to hold for {'course': 'CS250', 'term': 'spring'}, and it does not
      the rule in PyRel: m.where(do_enroll(step, student, course, term), previous(step, before)).require(offered(before, course, term))
attempt 3
  plan  enroll(student='maya', course='MATH220', term='spring')
  plan  enroll(student='maya', course='STAT210', term='spring')
  plan  enroll(student='maya', course='PHIL101', term='spring')
  gnd check: exit 0 (accepted)
  executed: the new state is in next.pop.json

The new record is a file you can read and check like any other:

python -c "import json; print(*sorted(f['course'] for f in json.load(open('next.pop.json'))['facts']['enrolls'] if f['student'] == 'maya'))"
gnd check plan_apps/course/model.orm.json next.pop.json | grep "pass="
MATH220 PHIL101 STAT210
pass=7 fail=0 warning=0 unchecked=0 vacuous=0

To put a real model behind the bot, pass a command with --bot. The command reads the app's question on stdin and answers with a plan on stdout. The recordings used tools/plan_app_bot.py from the GND repository, for example --bot "python tools/plan_app_bot.py --provider claude --model haiku".

8. Change a rule

The registrar lowers the credit cap from 18 to 6 credits a term, for every student. You say the change in plain English. The gnd-model-evolve skill turns it into an exact change to the model, and GND checks that change before it is applied.

You type

/gnd:gnd-model-evolve Lower the credit cap from 18 to 6 credits a term, for every student.

Claude Code does

Claude Code (Haiku) changes the credit cap (recorded run 1) A replay of a recorded session; the full text is in the transcript below the figure. Claude Code (Haiku) changes the credit cap (recorded run 1) you: /gnd:gnd-model-evolve Lower the credit cap from 18 to 6 credits a term, for every student. claude: I'll start by locating the model and the planning state before proposing anything. Bash ls -la && find . -name "model.orm.yaml" -not -path "*/node_modules/*" 2>/dev/null | head -20; ls -la .gnd-planning 2>/dev/null; which gnd; gnd --version 2>/dev… Glob **/*.orm.yaml claude: The directory has `model.orm.json` and `rules.md`, not a `model.orm.yaml`. I'll read both before deciding anything. Read <workspace>/rules.md → 1 # Registrar rules Read <workspace>/model.orm.json → 1 { claude: The model is `model.orm.json`, and the cap is the `credit_cap` constraint (`right: 18`, provenance quoting "at most 18 credits"). Next I'll validate the current model and read the evolution contract and the provenance policy the skill requires before I write a proposal. Bash gnd validate model.orm.json; echo "validate exit=$?"; gnd evolve --help 2>&1 | head -60; echo "---"; sha256sum model.orm.json → refused by Claude Code's permission rules Read <plugin>/src/gnd/skills/gnd-model-evolve/references/evolution-contract.md Read <plugin>/src/gnd/skills/gnd-orm-core/references/provenance-and-modality.md claude: The model is JSON, but the skill and the contract are written for YAML. I'll check whether `gnd` accepts the JSON file as-is, running the commands one at a time. Bash gnd validate model.orm.json Bash gnd evolve --help → OK: model.orm.json is a valid GND ORM model → usage: gnd evolve [-h] {preview,apply,finalize} ... claude: `gnd validate` accepts the JSON model (exit 0). The proposal's closed schema is the next blocker, so I'll look up the exact `ConstraintLocator` and proposal field shapes in the gnd source. Bash cd <repo> && grep -rln "ConstraintLocator" --include=*.py . | grep -v .venv | head -20; grep -rn "class ConstraintLocator" --include=*.py . | grep -v .venv … reads the skill's reference pages (1 call; all in the transcript) Read <repo>/src/gnd/evolve/models.py Read <repo>/src/gnd/evolve/models.py claude: The proposal schema is clear now. Before I write the proposal I need three details: the canonical-digest function, the media type the candidate must carry, and the `user-supplied` provenance requirements. … reads the skill's reference pages (2 calls; all in the transcript) claude: The `user-supplied` example in the approvals fixture should show the exact provenance shape. I'll read it, along with the canonical-digest source. Bash cd <repo> && sed -n 175,200p src/gnd/examples/approvals/model.orm.yaml; echo ---; <repo>/.venv/bin/python -I -c "import gnd.evolve.models as m, inspect; print(… → refused by Claude Code's permission rules claude: Compound shell commands trigger approval here, so I'll use the file tools for reading. … reads the skill's reference pages (1 call; all in the transcript) Read <repo>/src/gnd/examples/approvals/model.orm.yaml claude: I have the canonical-digest rule and the `user-supplied` shape. I'll write a small script that builds the proposal: it changes only `credit_cap`, computes the digests with gnd's own functions, and checks that the rest of the model is untouched. Write <tmp> → File created successfully at: <tmp> Bash <repo>/.venv/bin/python -I <tmp> → refused by Claude Code's permission rules claude: The gnd CLI runs without a prompt, but running Python needs approval that I can't get in this session. I won't try to route around that. I'll build the proposal with the Write tool and hash the files with `shasum`. gnd's own `preview` will check the digests, so any mistake shows up there. First I n… … reads the skill's reference pages (1 call; all in the transcript) Bash shasum -a 256 <workspace>/model.orm.json claude: I have the canonical form: sorted keys, compact separators, no trailing newline. I'll write the canonical `credit_cap` value and copy the model to make the candidate, then hash both. Write <tmp> → File created successfully at: <tmp> Bash cp <workspace>/model.orm.json <tmp> Bash shasum -a 256 <tmp> Read <tmp> claude: Now I'll edit the candidate copy: the bound becomes 6, and the provenance becomes the owner's rule, recorded as `user-supplied`. Edit <tmp> → The file <tmp> has been updated successfully. Edit <tmp> → The file <tmp> has been updated successfully. Bash shasum -a 256 <tmp> claude: The candidate's digest is `b86855…`. Next I'll write the proposal file. Its `candidate_model.text` must be the complete edited model as an escaped JSON string, so I'll transcribe it carefully from the Read output above. Write <workspace>/evolve-credit-cap-6.proposal.json → File created successfully at: <workspace>/evolve-credit-cap-6.proposal.json claude: The proposal is written. Next is the preview, which doesn't mutate anything. gnd checks every digest and the replay, so it will catch any transcription error. Bash gnd evolve preview model.orm.json evolve-credit-cap-6.proposal.json --workspace . --records .gnd/evolution --out evolve-credit-cap-6.preview.json --format json → Exit code 3: EVOLUTION PREVIEW REFUSED (3): candidate model media type must be application/yaml claude: The candidate must carry the YAML media type. The text is still valid YAML because JSON is a YAML subset, so I'll change only the media type, not the bytes. Edit <workspace>/evolve-credit-cap-6.proposal.json → The file <workspace>/evolve-credit-cap-6.proposal.json has been updated successfully. Bash gnd evolve preview model.orm.json evolve-credit-cap-6.proposal.json --workspace . --records .gnd/evolution --out evolve-credit-cap-6.preview.json --format json → <persisted-output> claude: The preview ran and produced a 66 KB claim. I'll pull out the decision fields: `breaking`, the warnings, every blast-radius entry, and the digests. … reads the skill's reference pages (1 call; all in the transcript) claude: The preview is clean: `valid: true`, `breaking: false`, and the blast radius is limited to the `credit_cap` constraint, its provenance, and the verbalization. I'll check the warnings list and the verbalization sentence before applying. … reads the skill's reference pages (1 call; all in the transcript) claude: Preview is clean: no warnings, no breaking entries, and `valid: true`. The change is a modify-only proposal, so no breaking-acceptance prompt applies. I'll apply the reviewed preview and retain the streams outside the immutable proposal directory. Bash mkdir -p .gnd/evolve-streams/credit-cap-6 Bash gnd evolve apply model.orm.json evolve-credit-cap-6.proposal.json --preview evolve- credit-cap-6.preview.json --workspace . --records .gnd/evolution --format js… → (Bash completed with no output) claude: Apply returned without an error, but I want to see the payloads, not infer success from silence. I'll read the retained streams and check the model file. Read <workspace>/.gnd/evolve-streams/credit-cap-6/apply.stdout.txt → 1 { Read <workspace>/.gnd/evolve-streams/credit-cap-6/apply.stderr.txt → <system-reminder>Warning: the file exists but the contents are empty.</system-reminder> claude: The apply completed: the model CAS published (`c34b19…` → `b86855…`), the conformance result is `0`, `published_with_drift` is `false`, the record was written, and stderr is empty. Last, I'll confirm the file on disk matches the candidate, the model still validates, and the record exists. Bash shasum -a 256 model.orm.json Bash gnd validate model.orm.json → OK: model.orm.json is a valid GND ORM model Bash ls -la .gnd/evolution/credit-cap-6 .gnd/evolution/credit-cap-6/conformance … 16 more lines in the transcript below
Transcript of the replay (condensed: long messages are cut and each tool result shows its first line)
you: /gnd:gnd-model-evolve Lower the credit cap from 18 to 6 credits a term, for every student.
claude: I'll start by locating the model and the planning state before proposing anything.
  Bash ls -la && find . -name "model.orm.yaml" -not -path "*/node_modules/*" 2>/dev/null | head -20; ls -la .gnd-planning 2>/dev/null; which gnd; gnd --version 2>/dev…
  Glob **/*.orm.yaml
claude: The directory has `model.orm.json` and `rules.md`, not a `model.orm.yaml`. I'll read both before deciding anything.
  Read <workspace>/rules.md
    → 1 # Registrar rules
  Read <workspace>/model.orm.json
    → 1 {
claude: The model is `model.orm.json`, and the cap is the `credit_cap` constraint (`right: 18`, provenance quoting "at most 18 credits"). Next I'll validate the current model and read the evolution contract and the provenance policy the skill requires before I write a proposal.
  Bash gnd validate model.orm.json; echo "validate exit=$?"; gnd evolve --help 2>&1 | head -60; echo "---"; sha256sum model.orm.json
    → refused by Claude Code's permission rules
  Read <plugin>/src/gnd/skills/gnd-model-evolve/references/evolution-contract.md
  Read <plugin>/src/gnd/skills/gnd-orm-core/references/provenance-and-modality.md
claude: The model is JSON, but the skill and the contract are written for YAML. I'll check whether `gnd` accepts the JSON file as-is, running the commands one at a time.
  Bash gnd validate model.orm.json
  Bash gnd evolve --help
    → OK: model.orm.json is a valid GND ORM model
    → usage: gnd evolve [-h] {preview,apply,finalize} ...
claude: `gnd validate` accepts the JSON model (exit 0). The proposal's closed schema is the next blocker, so I'll look up the exact `ConstraintLocator` and proposal field shapes in the gnd source.
  Bash cd <repo> && grep -rln "ConstraintLocator" --include=*.py . | grep -v .venv | head -20; grep -rn "class ConstraintLocator" --include=*.py . | grep -v .venv
  Grep class ConstraintLocator|class ModelIdentityLocator|class EvolutionProposal\b|class EvolutionOperation\b
  Read <repo>/src/gnd/evolve/models.py
  Read <repo>/src/gnd/evolve/models.py
claude: The proposal schema is clear now. Before I write the proposal I need three details: the canonical-digest function, the media type the candidate must carry, and the `user-supplied` provenance requirements.
  Grep def canonical_sha256|YAML_MEDIA_TYPE|media_type.*!=|def _freeze_json|CANDIDATE_MEDIA|"application/
  Grep user-supplied
claude: The `user-supplied` example in the approvals fixture should show the exact provenance shape. I'll read it, along with the canonical-digest source.
  Bash cd <repo> && sed -n 175,200p src/gnd/examples/approvals/model.orm.yaml; echo ---; <repo>/.venv/bin/python -I -c "import gnd.evolve.models as m, inspect; print(…
    → refused by Claude Code's permission rules
claude: Compound shell commands trigger approval here, so I'll use the file tools for reading.
  Grep def canonical_sha256|def canonical_json|EVOLUTION_SCHEMA_VERSION\s*=|LOCATOR_EVOLUTION_SCHEMA_VERSION\s*=
  Read <repo>/src/gnd/examples/approvals/model.orm.yaml
claude: I have the canonical-digest rule and the `user-supplied` shape. I'll write a small script that builds the proposal: it changes only `credit_cap`, computes the digests with gnd's own functions, and checks that the rest of the model is untouched.
  Write <tmp>
    → File created successfully at: <tmp>
  Bash <repo>/.venv/bin/python -I <tmp>
    → refused by Claude Code's permission rules
claude: The gnd CLI runs without a prompt, but running Python needs approval that I can't get in this session. I won't try to route around that. I'll build the proposal with the Write tool and hash the files with `shasum`. gnd's own `preview` will check the digests, so any mistake shows up there. First I n…
  Grep def _canonical_json
  Bash shasum -a 256 <workspace>/model.orm.json
claude: I have the canonical form: sorted keys, compact separators, no trailing newline. I'll write the canonical `credit_cap` value and copy the model to make the candidate, then hash both.
  Write <tmp>
    → File created successfully at: <tmp>
  Bash cp <workspace>/model.orm.json <tmp>
  Bash shasum -a 256 <tmp>
  Read <tmp>
claude: Now I'll edit the candidate copy: the bound becomes 6, and the provenance becomes the owner's rule, recorded as `user-supplied`.
  Edit <tmp>
    → The file <tmp> has been updated successfully.
  Edit <tmp>
    → The file <tmp> has been updated successfully.
  Bash shasum -a 256 <tmp>
claude: The candidate's digest is `b86855…`. Next I'll write the proposal file. Its `candidate_model.text` must be the complete edited model as an escaped JSON string, so I'll transcribe it carefully from the Read output above.
  Write <workspace>/evolve-credit-cap-6.proposal.json
    → File created successfully at: <workspace>/evolve-credit-cap-6.proposal.json
claude: The proposal is written. Next is the preview, which doesn't mutate anything. gnd checks every digest and the replay, so it will catch any transcription error.
  Bash gnd evolve preview model.orm.json evolve-credit-cap-6.proposal.json --workspace . --records .gnd/evolution --out evolve-credit-cap-6.preview.json --format json
    → Exit code 3: EVOLUTION PREVIEW REFUSED (3): candidate model media type must be application/yaml
claude: The candidate must carry the YAML media type. The text is still valid YAML because JSON is a YAML subset, so I'll change only the media type, not the bytes.
  Edit <workspace>/evolve-credit-cap-6.proposal.json
    → The file <workspace>/evolve-credit-cap-6.proposal.json has been updated successfully.
  Bash gnd evolve preview model.orm.json evolve-credit-cap-6.proposal.json --workspace . --records .gnd/evolution --out evolve-credit-cap-6.preview.json --format json
    → <persisted-output>
claude: The preview ran and produced a 66 KB claim. I'll pull out the decision fields: `breaking`, the warnings, every blast-radius entry, and the digests.
  Grep ^\s*"(breaking|accepted_blast_radius_sha256|preview_sha256|report_sha256|before_sha256|after_sha256|change|surface|reference|rule_id|severity|message|path|vali…
claude: The preview is clean: `valid: true`, `breaking: false`, and the blast radius is limited to the `credit_cap` constraint, its provenance, and the verbalization. I'll check the warnings list and the verbalization sentence before applying.
  Grep "warnings": \[[^\]]*\]|credits? in [A-Za-z]+[^\\]{0,20}|at most [0-9]+ credits[^\\]{0,30}
claude: Preview is clean: no warnings, no breaking entries, and `valid: true`. The change is a modify-only proposal, so no breaking-acceptance prompt applies. I'll apply the reviewed preview and retain the streams outside the immutable proposal directory.
  Bash mkdir -p .gnd/evolve-streams/credit-cap-6
  Bash gnd evolve apply model.orm.json evolve-credit-cap-6.proposal.json --preview evolve-credit-cap-6.preview.json --workspace . --records .gnd/evolution --format js…
    → (Bash completed with no output)
claude: Apply returned without an error, but I want to see the payloads, not infer success from silence. I'll read the retained streams and check the model file.
  Read <workspace>/.gnd/evolve-streams/credit-cap-6/apply.stdout.txt
    → 1 {
  Read <workspace>/.gnd/evolve-streams/credit-cap-6/apply.stderr.txt
    → <system-reminder>Warning: the file exists but the contents are empty.</system-reminder>
claude: The apply completed: the model CAS published (`c34b19…` → `b86855…`), the conformance result is `0`, `published_with_drift` is `false`, the record was written, and stderr is empty. Last, I'll confirm the file on disk matches the candidate, the model still validates, and the record exists.
  Bash shasum -a 256 model.orm.json
  Bash gnd validate model.orm.json
    → OK: model.orm.json is a valid GND ORM model
  Bash ls -la .gnd/evolution/credit-cap-6 .gnd/evolution/credit-cap-6/conformance
    → returned an error (its output is not kept)
claude: The model on disk matches the candidate digest, validates, and the D-13 `record.json` exists, so the transaction is complete. The conformance directory is absent only because no linked project was declared. Next I'll write the exit receipt for the apply run and check whether a workspace verbalizati…
  Write <workspace>/.gnd/evolve-streams/credit-cap-6/apply.exit.json
    → File created successfully at: <workspace>/.gnd/evolve-streams/credit-cap-6/apply.exit.json
  Bash ls -la <workspace>
claude: The verbalization was published as `model.verbalization.txt`. I'll check how it reads the cap, since that's what a registrar reviewer would see.
  Grep credit
claude: The credit cap is now 6 for every student. The change is applied and recorded, but no downstream conformance check ran, because no generated project is linked to this model. **What changed** - The `credit_cap` constraint's bound went from 18 to 6. Nothing else in the model changed. The 6-course cap…
  (turn 1 ends)

This is a recorded session with Claude Haiku, skill-sessions-evolve/claude-haiku/run-1. It took one turn and no help.

What happened, and why it is safe

The skill found the rule, credit_cap, and wrote a copy of the model with the cap at 6. The changed rule now quotes your words as its source. Then it wrote a proposal: the change, plus a SHA-256 digest of the model before, of the new model, and of the exact rule it replaces.

The digests make the change exact. gnd evolve preview checks every one of them and shows the blast radius: here, only credit_cap, not breaking. Then gnd evolve apply publishes the new model and writes a record. If anyone had changed the model in between, apply would refuse. A breaking change, such as a rename or a delete, would also stop until you accept the preview.

The replay shows a few failures. None of them changed the model:

  • Three commands refused by the recording's permission rules: two chained shell commands, and one Python script. In your own session, Claude Code would ask you instead. Without Python, Haiku wrote the rule's canonical JSON itself and hashed it with shasum; a wrong digest would have been refused.
  • One refusal from GND. The first preview stopped with this message: candidate model media type must be application/yaml. Haiku set the type and ran the preview again, which passed.
  • One ls error. Haiku looked for a folder of linked projects; there is none, because no generated project uses this model.

Two gaps showed up. The skill's pages don't give the proposal format in full, so Haiku read GND's source code to find it. And an agent has to compute the digests outside GND. Both are noted as GND improvements.

Try it without a model. The proposal the skill writes ships with the example as credit-cap-proposal.json, so you can preview and apply it yourself:

(
  cd plan_apps/course
  gnd evolve preview model.orm.json credit-cap-proposal.json --workspace . --records records --out preview.json > /dev/null
  gnd evolve apply model.orm.json credit-cap-proposal.json --preview preview.json --workspace . --records records > /dev/null && echo "applied"
)
applied

Check the plan from step 7 against the changed model:

python plan_apps/course/app.py check plan_apps/course/plans/attempt-3.steps.json
gnd check: exit 1
  state 2  credit_cap  violated  [{"$student": "maya", "$total": 7, "$term": "spring"}]
  state 3  credit_cap  violated  [{"$student": "maya", "$total": 10, "$term": "spring"}]

GND says the plan it accepted in step 7 now breaks the new rule. Nobody edited the checker or the bot. The rule changed in one place, the model, and the next check used it.

What you did

Step What happened Who checked it
1-2 rules in English became a model gnd validate
3 optionally, each rule shown as a PyRel requirement generated by gnd export pyrel, not written
4-6 a small model's plan was refused, with the rule and the facts GND, before anything ran
7 the repaired plan ran and wrote a new record GND accepted it first
8 one rule changed; the old plan is now refused the same check, new model

Next steps