Feature stories

Three fields about your own product, three stages, and you write no code. The sentence you already say out loud becomes the story your agent reads, then the skeleton it builds.

The engine

Three fields go in. Three stages later, the same file exists.

Chapter 3 took one finished file and read it four ways, backwards. This chapter runs the other direction: three fields go in, and three stages later the same file exists. Put your own product in the fields, or leave the billing defaults where they are. Press a stage, or scroll the section on a wide screen.

The owner speaking. The three fields snap into the persona sentence the conventions state, each field highlighted where it lands.

The plan speaking. The same story restated as “When … is provided, then …”, and then expanded until no jargon is left.

The agent speaking. A real Crystal file, named end to end, every body still empty, in the file billing/apply_late_fee.cr.

the owner's request

Stage 1 · The story

As an Admin user, I want to perform a late fee charge on an Invoice that is past its grace period and notify the Customer it belongs to.

perform is the flag. It is the one verb that is not RESTful, so what gets built is a process manager rather than a controller action. And the word “its” was hiding a relationship: the invoice belongs to the customer. Neither of those was a decision the agent had to invent. Both were already in the sentence.

Stage 2 · The When-statement

First plainly:

initializeWhen an Invoice and the number of days it is past due are provided, performthen stop unless the invoice is overdue, charge the late fee, and tell the customer.

Then expanded, until every piece of jargon is spelled out in models, attributes, and operations:

initializeWhen an Invoice record and an Integer representing how many days past its due date that invoice is today are provided, performthen compare that number against the company's grace period in days and do nothing if it is smaller, otherwise charge the invoice a fee equal to the late fee rate times the amount owed, and email the customer the invoice belongs to a notice of the fee.

The when clause is now the initialize parameters. The then clause is perform, in order. That expanded sentence is nearly the pseudocode, and that is the point.

Stage 3 · The skeleton

1# File found under `billing/apply_late_fee`
2# Applies a late fee to the invoice once it has gone past its grace period, then tells the customer.
3class Billing:::ApplyLateFee
4 # -- What this needs --
5
6 # The invoice being charged. It knows its customer, its amount, and its due date.
7 property invoice : Invoice
8
9 # How many days past the due date the invoice is today.
10 property how_many_days_late : Int32 = 0
11
12 # -- Company policy --
13
14 # Days past due before any fee applies.
15 property grace_period_in_days : Int32 = 30
16
17 # The fee, as a share of the amount owed.
18 property late_fee_rate : Float64 = 0.05
19
20 # -- What we learned along the way --
21
22 # True once the notice has gone out.
23 property has_the_customer_been_told : Bool = false
24
25 def initialize(@invoice, @how_many_days_late)
26 end
27
28 # -- How it runs --
29
30 def perform
31 stop_unless_the_invoice_is_overdue
32 charge_the_late_fee
33 tell_the_customer
34 end
35
36 # Nothing happens inside the grace period.
37 private def stop_unless_the_invoice_is_overdue
38 # Your business logic goes here
39 end
40
41 # The fee is a share of the amount owed, charged to the same invoice.
42 private def charge_the_late_fee
43 # Your business logic goes here
44 end
45
46 # The customer gets the notice by email.
47 private def tell_the_customer
48 # Your business logic goes here
49 end
50end

That is the file chapter 3 read four ways, arrived at from a sentence instead of from a refactor. The bodies are still empty, and nothing in the names needs to change when they fill.

The three fields

Who is asking, what you want to do, and the thing it happens to.

Persona: who is asking. A persona is a specialized alias of a user model type that carries an authorization level with it. When you name one, you have also said that this feature needs a view and a controller action at a minimum, and which authorization surface the route belongs to. Leave it out and the story is a process and nothing more. The default in the field is Admin.

What you want to do: the verb. Either an HTTP verb followed by the primary data model, in which case framework CRUD does the work, or perform, the one non-RESTful verb, the one you use for your own business logic. The default in the field is perform. Choose a RESTful verb in the field and the story says so: framework CRUD builds the controller action and the view, and you write no process manager. The two stages after the story stay on the perform branch, which is where your own business logic goes.

The thing it happens to: the subject and its relations. The primary data model, plus any model the action needs a relationship with. Whether you say “a” or “multiple” is what decides the relationship, in the same Active Record expressions Rails uses. The default in the field is Invoice, related to Customer.

One honest note about the source. Chapter 04 of the conventions repository states the story's anatomy and then marks its own breakdown of that anatomy TBD. The section is genuinely unwritten, and the release marks it rather than papering over it. The three stages above follow the AED planning skill, which does spell the breakdown out and carries the same worked example.

The rules this chapter settles

A story that carries its own nouns, verbs, and qualifying information hands the agent every name before any code exists.

  • A feature story is a user story you keep with your code, not in a tracker.“Instead of managing the details of the story from a 3rd party tool such as Jira, we are going to manage the details directly with our AI agent that works from our code base.”
  • The story has a fixed anatomy.The conventions state it as a single template, and every stage on this page is that template filled in.
    As a (specify persona), I want to (RESTful verb, or “perform”) (“a” or “multiple”) (data model name of an existing data model) and (AR relationship name/type or “perform”) (data model name or Process Manager name if performing a process)
  • A persona means a view and a controller action at a minimum.“When there is a persona we have chosen, we also know the feature request requires a view and a controller action at a minimum.” A story with no persona does not.
  • The action verb is an HTTP verb followed by the primary data model the action affects.
    GET · POST · PUT · PATCH · DELETE
  • perform is the one non-RESTful verb, and it is where your own business logic goes.“A unique verb of ‘perform’ can be used to trigger a workflow that is not RESTful. This is the keyword you primarily use for our unique business logic, aka your special sauce.” A perform story means you are writing a process manager, not a controller action.
    perform
    Billing::ApplyLateFee
  • The story names every related model, and plurality decides the relationship.“The action description should also include references to any data models that require a relationship”, and “the plurality of the data models dictates the relationships between them, and should use the same Active Record pattern expressions as in Rails.”
  • Company jargon is a flag, not a name.“Assign” aliases a belongs_to. If a word in the story is company jargon, define it in the plan before deriving names from it; ask the person who said it if you cannot.
    assignbelongs_to
  • Every process is written as a When-statement.“Processes start with a ‘when’ keyword, always. Because a process is ‘when’ something happens!” The when clause names the qualifying information the process needs before it can run. The then clause names the operations.
    wheninitialize
    thenperform
  • Expand the When-statement until no jargon is left.“That expanded sentence is nearly the pseudocode. That is the point.”
  • Read the story as a grammar exercise.Nouns become data models and attributes; verbs become process managers and methods.
  • “Pseudocode and plans use release names. Never placeholders.”And the reason: “Placeholder names in a plan are not neutral — they are instructions.”
  • Check every name while it still exists in exactly one place, the plan, and fix every warning then, before any code is written.Note per file where each class lives: the file is the lower snake_case of its primary class, and a namespaced class lives in a folder named for the namespace.
    Billing::ApplyLateFeebilling/apply_late_fee.cr
One written card above an empty grid: the story exists before any code does

Why it matters to the agent

The plan is a token window too, and placeholder names ship.

This is chapter 3's argument moved one step earlier. There, the point was that a name carries its meaning inside every token window that contains it. Here, the window in question is the one the agent reads before any code exists at all: the plan. The model writing the implementation reads the plan and follows it, so data, options, process, and TODO: helper here are not gaps waiting to be filled in later. They are instructions, and they ship.

A story that carries its own nouns, verbs, and qualifying information puts every name into that window while the names still live in one place. Nothing has to be invented on the way down, which is why the rename pass at the end of the feature shrinks toward zero. There is no “we will clean up the names before merge” step, because there is nothing left to clean.

The conventions repository publishes two measurements next to this claim, and both are narrower than the claim. In a comprehension benchmark run on 2026-07-07, Claude Haiku answered intent, modification, and defect probes on ten pairs of Crystal snippets, blind, with a blind grader: AED style scored 60 of 60 and conventional style 54 of 60, with the gap concentrated in defect-finding and intent. The report calls that “a directional signal consistent with the hypothesis, not proof of it.”

In the pet-tracker build-off of 2026-08-11, the same task was given to headless Haiku twice in a codebase before AED adoption and twice in the same codebase after: of five working user journeys, the before runs delivered none and none, the after runs two and four. The build-off's own README notes the arms differ by the whole AED bundle, not by names alone. Neither result is about feature stories in particular. Both are about what a model does with code whose names carry intent, and the story is how those names get there first.

Reading with an agent? Hand it the machine edition of this chapter. The conventions themselves are chapter 04 of the conventions repository; the two measurements are the comprehension benchmark of 2026-07-07 and the pet-tracker build-off of 2026-08-11.

Before and after

The same feature, planned two ways: once with placeholder names, once with names derived from the story.

The request was identical; what changed is whether the plan handed the agent names or handed it placeholders.

the plan, before and after
the plan most of us write
1# before -- a plan whose names are placeholders, and therefore instructions
2class LateFeeService
3 def initialize(invoices, options)
4 @invoices = invoices
5 @options = options
6 end
7
8 def process
9 data = fetch_data
10 handle_invoices(data)
11 notify(data)
12 end
13end
derived from the story
1# after -- the same feature, derived from the story and the When-statement
2class Billing:::ApplyLateFee
3 property invoice : Invoice
4 property how_many_days_late : Int32 = 0
5 property grace_period_in_days : Int32 = 30
6 property late_fee_rate : Float64 = 0.05
7 property has_the_customer_been_told : Bool = false
8
9 def initialize(@invoice, @how_many_days_late)
10 end
11
12 def perform
13 stop_unless_the_invoice_is_overdue
14 charge_the_late_fee
15 tell_the_customer
16 end
17end

Nothing in the before block says what qualifies an invoice, what the grace period is, or what the customer is told. Its data, options, and handle_invoices are the names that will ship.

A plan also states the supporting model changes it implies, in the same pass. This one does: customer.cr gains property is_this_an_enterprise_customer : Bool and renames subscriptions to list_of_all_active_subscriptions, because a plan that renames a model attribute in passing has already decided that rename.

Check the names

Check the names before you write the code.

Every name above exists in exactly one place right now. That is the cheapest moment there will ever be to check it, so check it in a batch, before the skeleton is written:

zsh · your machine
$ ruby ${CLAUDE_PLUGIN_ROOT}/scripts/aed_lint.rb check-name --kind class Billing::ApplyLateFee$ ruby ${CLAUDE_PLUGIN_ROOT}/scripts/aed_lint.rb check-name --kind attribute how_many_days_late grace_period_in_days late_fee_rate$ ruby ${CLAUDE_PLUGIN_ROOT}/scripts/aed_lint.rb check-name --kind boolean has_the_customer_been_told$ ruby ${CLAUDE_PLUGIN_ROOT}/scripts/aed_lint.rb check-name --kind method stop_unless_the_invoice_is_overdue charge_the_late_fee tell_the_customer$ ruby ${CLAUDE_PLUGIN_ROOT}/scripts/aed_lint.rb check-name --kind collection list_of_all_active_subscriptions

--kind is one of boolean, collection, attribute, class, or method.

Fix every warning now, while the fix is one edit in a plan rather than a refactor across a branch. Then write the path down next to the class, because the plan decides that too: Billing::ApplyLateFee lives in billing/apply_late_fee.cr.

If you catch yourself about to write a placeholder name “just for the skeleton,” that is the signal that the When-statement is not finished. Expand it until the real name is obvious, then write the real name.