Agent Course/agent-course

Chapter 6: A Plan That Changes What Exists

The first plan only added things. Registrations cannot: a meetup has to learn about its registrations, its page has to show the seats left, and code you already accepted has to change. This chapter plans that change and approves it. The review gets harder, because now the question is not only "is this right?" but also "what else does it touch?"

Chapter 6: A Plan That Changes What Exists

The first plan only added things. Registrations cannot: a meetup has to learn about its registrations, its page has to show the seats left, and code you already accepted has to change. This chapter plans that change and approves it. The review gets harder, because now the question is not only "is this right?" but also "what else does it touch?"

What you'll learn:

  • How a plan marks what it changes (alter) apart from what it adds
  • How to read Impact, the page's list of code that depends on a changed element
  • Why approval can warn about an alter, and what that warning asks of you

1. Ask for the plan

Send this prompt to the Claude Code session:

Plan registrations with the plan-write skill: a signed-in user registers for a meetup and can cancel their own registration. A meetup never takes more registrations than its capacity, and its page shows the seats left.

The agent reads docs/entities/Meetup.md from chapter 5 as part of guren context Meetup, so it knows the existing rules before it plans against them. Leave one question open this time: what happens when someone registers for a full meetup.

Without an agent:

mkdir -p docs/plans/registrations
docs/plans/registrations/plan.json
docs/plans/registrations/plan.json{
  "planVersion": 1,
  "title": "Registrations",
  "summary": "Signed-in users register for a meetup until it is full and cancel their own registration. The meetup page shows the seats left.",
  "locale": "en",
  "scope": {"goals": ["Register for a meetup", "Cancel your own registration", "See the seats left"], "nonGoals": ["Paid tickets", "Registering someone else"]},
  "assumptions": ["The organizer can register for their own meetup"],
  "questions": [
    {
      "id": "Q-full",
      "question": "What happens when someone registers for a full meetup?",
      "options": [
        {"label": "refuse", "consequence": "No row is written and the page says the meetup is full."},
        {"label": "waitlist", "consequence": "A waitlisted registration is written and promoted when someone cancels."}
      ],
      "assumed": "refuse",
      "affects": ["model.registration", "action.registrations.store"]
    }
  ],
  "models": [
    {
      "id": "model.user",
      "change": {"kind": "existing"},
      "name": "User",
      "table": "users",
      "columns": [{"id": "column.user.id", "name": "id", "change": {"kind": "existing"}, "type": "integer", "nullable": false, "unique": false, "index": false, "primaryKey": true}],
      "relationships": [],
      "fillable": []
    },
    {
      "id": "model.meetup",
      "change": {"kind": "alter"},
      "name": "Meetup",
      "table": "meetups",
      "columns": [
        {
          "id": "column.meetup.id",
          "name": "id",
          "change": {"kind": "existing"},
          "type": "integer",
          "nullable": false,
          "unique": false,
          "index": false,
          "primaryKey": true
        }
      ],
      "relationships": [{"name": "registrations", "type": "hasMany", "target": "model.registration"}],
      "fillable": []
    },
    {
      "id": "model.registration",
      "change": {"kind": "add"},
      "name": "Registration",
      "table": "registrations",
      "columns": [
        {
          "id": "column.registration.id",
          "name": "id",
          "change": {"kind": "add"},
          "type": "integer",
          "nullable": false,
          "unique": false,
          "index": false,
          "primaryKey": true
        },
        {
          "id": "column.registration.meetupId",
          "name": "meetupId",
          "columnName": "meetup_id",
          "change": {"kind": "add"},
          "type": "integer",
          "nullable": false,
          "unique": false,
          "index": true,
          "references": {"model": "model.meetup", "column": "id", "onDelete": "cascade"}
        },
        {
          "id": "column.registration.userId",
          "name": "userId",
          "columnName": "user_id",
          "change": {"kind": "add"},
          "type": "integer",
          "nullable": false,
          "unique": false,
          "index": true,
          "references": {"model": "model.user", "column": "id", "onDelete": "cascade"}
        }
      ],
      "relationships": [{"name": "meetup", "type": "belongsTo", "target": "model.meetup"}, {"name": "user", "type": "belongsTo", "target": "model.user"}],
      "fillable": [],
      "indexes": [{"columns": ["meetupId", "userId"], "unique": true}]
    }
  ],
  "controllers": [
    {
      "id": "controller.meetups",
      "change": {"kind": "alter"},
      "className": "MeetupController",
      "actions": [
        {
          "id": "action.meetups.show",
          "change": {"kind": "alter"},
          "name": "show",
          "authorization": {"middleware": []},
          "response": {"kind": "inertia", "view": "view.meetups.show"},
          "rules": ["Pass the signed-in user's registration, if any."]
        }
      ]
    },
    {
      "id": "controller.registrations",
      "change": {"kind": "add"},
      "className": "RegistrationController",
      "actions": [
        {
          "id": "action.registrations.store",
          "change": {"kind": "add"},
          "name": "store",
          "authorization": {"middleware": ["auth"]},
          "response": {"kind": "redirect", "to": "/meetups/:id"},
          "rules": ["The registrant is the signed-in user.", "A full meetup writes no row.", "Registering twice writes no second row."]
        },
        {
          "id": "action.registrations.destroy",
          "change": {"kind": "add"},
          "name": "destroy",
          "authorization": {"middleware": ["auth"], "policy": {"id": "policy.registration", "ability": "delete"}},
          "response": {"kind": "redirect", "to": "/meetups/:meetupId"},
          "rules": []
        }
      ]
    }
  ],
  "routes": [
    {
      "id": "route.meetups.show",
      "change": {"kind": "existing"},
      "method": "GET",
      "path": "/meetups/:id",
      "name": "meetups.show",
      "action": "action.meetups.show",
      "middleware": [],
      "bind": [{"param": "id", "model": "model.meetup"}]
    },
    {
      "id": "route.registrations.store",
      "change": {"kind": "add"},
      "method": "POST",
      "path": "/meetups/:id/registrations",
      "name": "registrations.store",
      "action": "action.registrations.store",
      "middleware": ["auth"],
      "bind": [{"param": "id", "model": "model.meetup"}]
    },
    {
      "id": "route.registrations.destroy",
      "change": {"kind": "add"},
      "method": "DELETE",
      "path": "/registrations/:id",
      "name": "registrations.destroy",
      "action": "action.registrations.destroy",
      "middleware": ["auth"],
      "bind": [{"param": "id", "model": "model.registration"}]
    }
  ],
  "views": [
    {
      "id": "view.meetups.show",
      "change": {"kind": "alter"},
      "page": "meetups/Show",
      "purpose": "Show a meetup with the seats left, and a button to register or cancel.",
      "props": [{"name": "meetup", "type": "Data.Meetup", "resource": "resource.meetup"}, {"name": "registrationId", "type": "number | null"}],
      "actions": [{"label": "Register", "route": "route.registrations.store"}, {"label": "Cancel", "route": "route.registrations.destroy"}],
      "states": {"empty": "The meetup is full."}
    }
  ],
  "resources": [
    {
      "id": "resource.meetup",
      "change": {"kind": "alter"},
      "name": "MeetupResource",
      "model": "model.meetup",
      "fields": [
        {"name": "id", "type": "number"},
        {"name": "title", "type": "string"},
        {"name": "startsAt", "type": "string"},
        {"name": "capacity", "type": "number"},
        {"name": "seatsLeft", "type": "number"}
      ]
    }
  ],
  "policies": [
    {
      "id": "policy.registration",
      "change": {"kind": "add"},
      "name": "RegistrationPolicy",
      "model": "model.registration",
      "abilities": [{"name": "delete", "rule": "The signed-in user made the registration."}]
    }
  ],
  "tasks": [
    {
      "id": "task.registrations",
      "entity": "Registration",
      "summary": "Register until the meetup is full, cancel your own registration, and see the seats left.",
      "covers": [
        "model.meetup",
        "model.registration",
        "controller.registrations",
        "action.meetups.show",
        "route.registrations.store",
        "route.registrations.destroy",
        "view.meetups.show",
        "resource.meetup",
        "policy.registration"
      ],
      "acceptance": [
        {
          "id": "AC-registrations-1",
          "description": "A signed-in user can register for a meetup with seats left.",
          "kind": "success",
          "actor": "user",
          "route": "route.registrations.store",
          "given": ["a meetup with 2 seats exists"],
          "expect": {"status": 303}
        },
        {
          "id": "AC-registrations-2",
          "description": "Registering for a full meetup writes no row.",
          "kind": "state",
          "actor": "user",
          "route": "route.registrations.store",
          "given": ["a meetup with 1 seat and 1 registration exists"],
          "expect": {"status": 303}
        },
        {
          "id": "AC-registrations-3",
          "description": "Registering twice writes no second row.",
          "kind": "state",
          "actor": "user",
          "route": "route.registrations.store",
          "given": ["the user is registered for a meetup"],
          "expect": {"status": 303}
        },
        {
          "id": "AC-registrations-4",
          "description": "A guest cannot register.",
          "kind": "unauthenticated",
          "actor": "guest",
          "route": "route.registrations.store",
          "given": ["a meetup exists"],
          "expect": {"redirect": "/login"}
        },
        {
          "id": "AC-registrations-5",
          "description": "A user cannot cancel someone else's registration.",
          "kind": "forbidden",
          "actor": "user",
          "route": "route.registrations.destroy",
          "given": ["another user's registration exists"],
          "expect": {"status": 403}
        },
        {
          "id": "AC-registrations-6",
          "description": "A guest cannot cancel a registration.",
          "kind": "unauthenticated",
          "actor": "guest",
          "route": "route.registrations.destroy",
          "given": ["a registration exists"],
          "expect": {"redirect": "/login"}
        },
        {
          "id": "AC-registrations-8",
          "description": "A user can cancel their own registration.",
          "kind": "success",
          "actor": "user",
          "route": "route.registrations.destroy",
          "given": ["the user is registered for a meetup"],
          "expect": {"status": 303}
        },
        {
          "id": "AC-registrations-7",
          "description": "The meetup page shows the seats left.",
          "kind": "success",
          "actor": "guest",
          "route": "route.meetups.show",
          "given": ["a meetup with 2 seats and 1 registration exists"],
          "expect": {"status": 200}
        }
      ]
    }
  ]
}

As in chapter 2, chapters 6 and 7 name elements of this reference plan. If you kept your own plan until now, this is the second point to switch: copy the block above over docs/plans/registrations/plan.json. That works only if your app also matches the reference at the end of chapter 5, so if you kept your own first plan, keep your own second one too and read the names as examples.

2. Add, alter, existing

bunx guren plan:render docs/plans/registrations/plan.json

Open docs/plans/registrations/plan.html and turn on Changes only. What remains sorts into three kinds:

Change Elements Meaning
add Registration, RegistrationController, two routes, RegistrationPolicy new code, as in the first plan
alter Meetup, MeetupResource, meetups/Show, MeetupController.show code that exists and will change
existing User, the meetups.show route referenced, not touched (hidden by Changes only)

An alter says what changes in properties Guren can read back from the code: Meetup gains a registrations relationship, MeetupResource a seatsLeft field, meetups/Show a registrationId prop. That is how the loop knows, later, that the change landed.

3. Read Impact

Every alter card has an Impact list: the code the scanners found depending on that element.

The MeetupResource card on the review page, marked alter. Its Impact list names the index, show and edit actions of MeetupController, their routes and ApiRoutes entries, the test requests reaching index and edit, and the meetups/Edit, Index and Show pages. Below it, the planned fields end with seatsLeft: number

Read it as a question: does the plan account for each of these? Here, MeetupResource gains seatsLeft, and the resource is used by index, show and edit. Each of them has to give the resource a registration count, or seatsLeft breaks on the list and edit pages. The plan changes only show; the other two are the agent's to notice in the http step, and yours to check in its commit.

The list is a lower bound. A consumer the scanners cannot see (code building a resource by hand, a raw query) is not on it.

Check On the page
Each alter states its change in a property, not only in prose the element's card
Every consumer in Impact is either covered by the plan or fine as it is Impact
Nothing listed under breaking changes surprises you Needs attention
A changed or dropped column says what happens to its rows (dataMigration) the column's card

This plan changes no column, so it needs no data migration. Chapter 2's checklist still applies on top: registrations.destroy sits behind a policy, and AC-registrations-8 is its success behaviour for the user who made the registration.

4. Answer and approve

Answer the question the same way as in chapter 3: pick refuse on the page, click Copy prompt for the agent, and paste the prompt into the Claude Code session. Add this below it before you send it:

A full meetup refuses the registration; a waitlist is a later change. Keep both warnings on registrations.store, since any signed-in user may register and the request has no body.

Without an agent:

bunx guren plan:revise docs/plans/registrations/plan.json --ops - <<'EOF'
{
  "ops": [
    {"op": "remove", "id": "Q-full", "reason": "Answered: a full meetup refuses the registration."},
    {"op": "modify", "section": "plan", "element": {"title": "Registrations", "summary": "Signed-in users register for a meetup until it is full and cancel their own registration. The meetup page shows the seats left.", "scope": {"goals": ["Register for a meetup", "Cancel your own registration", "See the seats left"], "nonGoals": ["Paid tickets", "Registering someone else"]}, "assumptions": ["The organizer can register for their own meetup", "A full meetup refuses a registration; a waitlist is a later change"], "hints": [], "locale": "en"}, "reason": "Record the answer to Q-full."}
  ]
}
EOF
git add docs/plans
git commit -m "docs: plan registrations"
bunx guren plan:approve docs/plans/registrations/plan.json

The approval stands, with one advisory warning about action.meetups.show. The plan changes what that action passes to its page, but the only property of an action Guren reads is which page it renders, and that holds already. Nothing in the code can show this change, so plan:status will never confirm it from properties alone. Only a verified behaviour that reaches the action can, and AC-registrations-7 does ("The meetup page shows the seats left").

The warning is a question for you: is there such a behaviour? Here there is, so you approve. Without one, the change would stay unconfirmed until someone waived it.

The approval also recorded, in approvals.json, how each alter read at this moment (the readings from the chapter 3 exercise). That is how the loop tells "changed by this plan" from "was already so".

git add docs/plans
git commit -m "docs: approve the registrations plan"

Where you are

  • An approved second plan that adds registrations and alters the meetup code from the first.
  • A habit for alter: read Impact, and ask whether each consumer is covered.

Common trip-ups

  • plan:render fails with "only existing is consistent there". An alter action sits under a controller marked existing. Mark the controller alter too.
  • Impact is empty for an element you know is used. The scanners follow imports and names they can resolve statically. Search the code yourself before trusting an empty list.

Exercises

  1. Remove AC-registrations-7 from a copy of the plan, save it outside the app, and render it with --app . and -o to a file outside the app too. Which check fails, and on which element?
  2. In docs/plans/registrations/approvals.json, read the readings for view.meetups.show. Which prop reads differ and which match, and which of the two can the loop later confirm as this plan's work?
Exercise 1: hint and an example answer

Look for the one alter that changes nothing Guren can read back from the code. The approval warning in section 4 names it.

A plan:acceptance check fails on action.meetups.show: the action is altered, but no acceptance behaviour names any of its routes. Its only route is route.meetups.show, and AC-registrations-7 was the only behaviour that requested it. The copy and the page stay outside the app because plan:approve and plan:next count any untracked file under the app root as uncommitted work.

Exercise 2: hint and an example answer

The readings sit on the approval entry, under readings.properties, each with element, property and verdict. A page's props are read by name from its Props interface.

prop registrationId reads differ, since Show.tsx declared only meetup at approval, and prop meetup reads match. The page's actions and states read unknown, because nothing reads what a page renders. Only registrationId can later count as this plan's work: a match on an alter counts only for a property its approval recorded as differ or unknown. meetup matched before any work began, so a match later proves nothing about this plan.

Next

Chapter 7: When the Application Moves builds this plan while a teammate changes the code underneath it.