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
course/rules.md: the rules as writtencourse/model.orm.json: the modelcourse/state.pop.json: the starting statecourse/app.py: the example's app (not a GND command)core.py: the code every example app sharescourse/stand_in_bot.py: the scripted stand-in botcourse/credit-cap-proposal.json: the proposal the evolve skill writesgnd-model-from-text: the skill's instructionsgnd-model-evolve: the skill's instructions- the modelling sessions: Claude Code running gnd-model-from-text
- the evolve sessions: Claude Code running gnd-model-evolve
- the recorded runs: every model run, with caveats
- this page's Markdown source: replays and included files show only as include lines there; they render on the docs site
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
Claude Code does
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:
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:
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
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
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
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="
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
Claude Code does
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
lserror. 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"
)
Check the plan from step 7 against the changed model:
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
- All recorded runs: five models, five apps, every run kept.
- PyRel: approval audits: the generated programs, and what they refuse.