Guide/guides

Writing an Implementation Plan

This page describes the plan file itself: its sections, element ids and changes, acceptance behaviours, questions and commands. For the workflow that renders, approves and implements it, see Implementation Plans; the words this page uses (element, behaviour, step) are defined in its Terms table.

Writing an Implementation Plan

This page describes the plan file itself: its sections, element ids and changes, acceptance behaviours, questions and commands. For the workflow that renders, approves and implements it, see Implementation Plans; the words this page uses (element, behaviour, step) are defined in its Terms table.

The document

Field Holds
planVersion 1
title, summary, locale What the plan is about, and the language its prose is written in (en, ja)
scope goals and nonGoals
assumptions, questions What the plan decided without being told, and what it could not decide
models, validators, controllers, routes, views, resources, policies, sideEffects The design, one section per kind of element
flows How a request moves through what the plan adds, as nodes and edges
commands Commands such as guren add attachments the change needs first
tasks What each slice must achieve, and its acceptance behaviours
hints Ordering advice, such as task/entity/model.tag before task/entity/model.comment
baseline Written by plan:approve, never by hand

Every section may be left out. An omitted section and an empty one are the same plan.

Ids and changes

Every element carries an id and a change. Ids share one namespace across the whole plan, start with a letter, and may contain letters, digits, _, ., : and -; the convention is the section and a name (model.comment, route.comments.store). Other elements refer to each other by these ids, and a revision later names an element by its id alone, so keep them stable.

change.kind Meaning
existing Referenced and left as it is
add New
alter Changed in place
rename Renamed; from is the old name (a model's class, a column's property, a route's name)
drop Removed; reason says why

An alter, rename or drop of an existing table or column must say what happens to its rows with dataMigration: { "kind": "none", "reason": "…" }, backfill or manual with a description. The plan is refused without one:

  column.post.summary: Column "summary" of "Post" is a "rename" on an existing table and states no dataMigration.

A model lists only the columns the plan touches or references. This is the new Comment model from the example, cut to two of its five columns:

{
  "id": "model.comment",
  "change": { "kind": "add" },
  "name": "Comment",
  "table": "comments",
  "columns": [
    {
      "id": "column.comment.body",
      "name": "body",
      "change": { "kind": "add" },
      "type": "text",
      "nullable": false,
      "unique": false,
      "index": false
    },
    {
      "id": "column.comment.postId",
      "name": "postId",
      "columnName": "post_id",
      "change": { "kind": "add" },
      "type": "integer",
      "nullable": false,
      "unique": false,
      "index": true,
      "references": { "model": "model.post", "column": "id", "onDelete": "cascade" }
    }
  ],
  "relationships": [
    { "name": "post", "type": "belongsTo", "target": "model.post" },
    { "name": "author", "type": "belongsTo", "target": "model.user" }
  ],
  "fillable": ["body"]
}

Column types are an abstract vocabulary rather than Drizzle builders: string, text, integer, number, decimal, boolean, date, datetime, json, uuid. datetime takes withTimezone, and decimal takes precision and scale.

The other sections follow the same pattern. A controller holds its actions, each with the validator its body, params or query uses, its authorization (middleware, and a policy ability), its response (an Inertia view, a redirect, a resource) and its business rules as prose. A route names its method, path, name, action id, middleware and bind. A view names its page id, its props, and a form whose fields point at a validator's fields rather than restating their rules. Elements in an application module carry module.

Acceptance behaviours

A task intent names an entity, the elements it covers, and the behaviours that prove it works:

{
  "id": "AC-comments-4",
  "description": "A user cannot delete someone else's comment.",
  "kind": "forbidden",
  "actor": "user",
  "route": "route.comments.destroy",
  "given": ["a comment written by another user exists"],
  "expect": { "status": 403 }
}

kind is one of success, validation, unauthenticated, forbidden, not-found and state. The checks count them: a route with a validator and no validation behaviour, or with authentication and no unauthenticated one, is reported. expect takes status, redirect, inertia, errors and database; request input and database values are written as { "name": "body", "json": "\"Nice post\"" }, the value as JSON text.

Each behaviour becomes a test whose title carries its id in brackets. Start acceptance ids with AC-: plan:verify reports a bracketed AC- token that the plan does not declare, which is how a mistyped id gets caught.

test("[AC-comments-4] a user cannot delete someone else's comment", async () => {
  const comment = await Comment.forceCreate({ body: 'Mine', postId: post.id, userId: author.id })
  await http.actingAs(reader).delete(`/comments/${comment!.id}`).assertStatus(403)
})

Guren redirects a non-GET request with 303, so a behaviour that expects the redirect after a form post says "status": 303.

Questions

A question is a decision the author could not make alone, with the options, the one the plan assumed, and the elements that change if the answer differs:

{
  "id": "Q-delete",
  "question": "Does deleting a comment remove the row?",
  "options": [
    { "label": "hard delete", "consequence": "The row is removed; no deleted_at column." },
    { "label": "soft delete", "consequence": "A deleted_at column is added and lists filter on it." }
  ],
  "assumed": "hard delete",
  "affects": ["model.comment", "action.comments.destroy"]
}

A plan with an open question cannot be approved. Answer it by editing the plan: apply the answer, remove the question, and record the decision under assumptions. Before approval, editing plan.json by hand is the normal way to change it.

Commands

plan:next hands a plan's commands to the implementing agent, which runs them as written. Each one therefore has to be a Guren generator:

{ "id": "command.attachments", "command": "guren add attachments", "reason": "Comments take images." }

A command passes when it reads guren <subcommand> or bunx guren <subcommand> and the subcommand is a make:* generator other than make:migration, lang:publish, or add <blueprint> other than add plugin. Arguments may hold letters, digits and _-.,:/=@+%, with single or double quotes around a value that has spaces (--fields "title:string,body:text?"). A shell operator, $, a backslash or an unclosed quote fails the check, and so does an argument that is an absolute path or climbs out with .. (--path /etc, --app=../other). The check bounds shell syntax and where a generator may write; it does not judge each generator's other flags, such as --force. Every other command fails too: bun run db:migrate runs as the data step's verify command, not from the plan. plan:approve refuses while this check fails, and plan:next hands out no step of a plan that carries such a command, draft or not.