Edit-level style

The edit, and the check that runs on it.

One edit, on its way to done

The moment

The structure was settled chapters ago. What is left is the line.

You have changed one method. The class is namespaced by feature and states its process, perform reads like the plan, each step is its own method. None of that is in question here. What is in question is the line you just wrote: whether somebody who has never seen it understands the intent on first pass, and whether the check that runs the second you save agrees the file still compiles.

That is the whole scope of this chapter: the individual edit, once the structure is already decided. Read alone, these six rules look as though AED were a six-item style guide, which it is not. Chapters 1 to 5 decide the shape. Chapter 6 tightens the line inside it, and never reopens the shape.

The window

One method, two panes. Everything outside it is byte for byte the same.

The window holds charge_the_late_fee twice. One tab names each pane, and the same method reads twice, once each way — first as it was written, then after the six rules. The case invoice.customer line in the before pane is flagged, because it is the line that fails the check. The class, the properties, and perform are settled work; every defect is inside the one private method.

billing · apply_late_fee.cr
apply_late_fee.cr — before
1# BEFORE: one method, written for the author
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
8 def perform
9 charge_the_late_fee
10 end
11
12 private def charge_the_late_fee
13 # charge the fee
14 return unless invoice.customer.try { |c| c.list_of_all_active_subscriptions.any?(&.active?) }
15
16 f = how_many_days_late < grace_period_in_days ? nil : (invoice.amount * late_fee_rate)
17 return if f.nil?
18
19 case invoice.customer
20 in Customers:::Enterprise then r = 0.0
21 in Customers:::Standard then r = f
22 end
23
24 invoice.charge(r, kind: :late_fee); invoice.save; CustomerMail.late_fee(invoice).deliver
25 end
26end
apply_late_fee.cr — after
1# AFTER: the same method, written for whoever reads it
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
8 def perform
9 charge_the_late_fee
10 end
11
12 # Enterprise agreements waive late fees by contract, so the type question is
13 # asked out loud rather than hidden behind a zero rate.
14 private def charge_the_late_fee
15 currently_active_subscription = invoice.customer.currently_active_subscription
16 return unless currently_active_subscription
17 return if how_many_days_late < grace_period_in_days
18
19 if invoice.customer.is_a?(Customers:::Enterprise)
20 record_that_the_late_fee_was_waived
21 else
22 charge_the_standard_late_fee
23 end
24 end
25
26 private def charge_the_standard_late_fee
27 late_fee_amount_owed = invoice.amount * late_fee_rate
28 invoice.charge(late_fee_amount_owed, kind: :late_fee)
29 invoice.save
30 CustomerMail.late_fee(invoice).deliver
31 end
32
33 private def record_that_the_late_fee_was_waived
34 invoice.note("Late fee waived under the enterprise agreement.")
35 end
36end

Nothing on this page animates and there are no stages to step through; it reads in full with JavaScript off.

Six moves, in rule order.

  • The type question.The case … in ladder asks which kind of customer this is, and hides the answer in a rate of zero. The if … is_a? asks it out loud, and the branch names say what each kind of customer gets: a waiver, or the standard fee.
    case … inif invoice.customer.is_a?(Customers::Enterprise)
  • The chain.The terse form makes you hold three operations at once: a lookup, a block binding, and a test, all inside a negated guard. Naming the value splits that into two plain statements. Here is the subscription we expected, and we stop unless it exists.
    invoice.customer.try { |c| … }currently_active_subscription = invoice.customer.currently_active_subscription
  • The ternary.One expression decided the grace period and produced a nilable amount, so the next line had to ask whether the amount was nil. Two guard clauses say the same thing in the order you would say it out loud: stop if there is no active subscription, stop if we are inside the grace period.
    f = … ? nil : …return if how_many_days_late < grace_period_in_days
  • The names.f and r carry their meaning only for the few minutes you still remember writing them. A full name carries it for everybody, and the two branch methods spell out the decision so no comment has to.
    flate_fee_amount_owed
    r = 0.0record_that_the_late_fee_was_waived
  • The comments.A comment that restates the next line is gone. The comment that survives states the contractual reason enterprise invoices are treated differently, the one thing the code genuinely cannot say.
    # charge the fee# Enterprise agreements waive late fees by contract, so the type question is asked out loud.
  • The layout.The semicolon line was three statements pretending to be one. Split, they are three lines a diff can show separately, and crystal tool format owns every other decision about the shape.
    invoice.charge(r, kind: :late_fee); invoice.save; CustomerMail…invoice.charge(late_fee_amount_owed, kind: :late_fee)
    invoice.save
    CustomerMail.late_fee(invoice).deliver

The before pane does not compile, which is the point. Run the check on it and the flagged line answers: Error: case is not exhaustive. Missing types: Grant::Base. In a codebase that uses the Grant ORM, models inherit from a class whose subtype set the compiler will not close, so an exhaustive case can never be satisfied. You get an error that names a type you never wrote, about a branch you thought you had covered.

The rules this chapter settles

Six rules, and one rule they all come from.

The guiding rule, first, because the six follow from it: “Prefer the form that reads like a plain statement of intent. Reach for shorthand only when it makes the intent clearer, never just shorter.”

  1. “Branch on type with an explicit if … is_a?, not a clever case.” case … in demands exhaustive matching and trips Grant's Grant::Base+ inference; case … when .is_a?(T) “compiles, but the leading dot is a riddle.” Here: if invoice.customer.is_a?(Customers::Enterprise).
  2. “Name the thing; don't make the reader decode a chain.” The terse form makes “the reader … hold three operations in their head.” Here: currently_active_subscription = invoice.customer.currently_active_subscription.
  3. “Prefer explicit guard clauses to nested ternaries / one-liners.” Here: return if how_many_days_late < grace_period_in_days, on its own line, instead of a ternary that produces a nilable fee.
  4. “Use full, intention-revealing names.” “Methods and locals are sentences-in-miniature… Avoid do_it, tmp, x, res2. A good name removes the need for a comment.” Here: late_fee_amount_owed, not f.
  5. “Say why in a comment, let the code say what.” “If a comment could be deleted with no loss because the code already says it, delete it.” Here: # charge the fee goes; the enterprise-agreement comment stays.
  6. “One statement per line; let the formatter own the layout.” “Run crystal tool format (or your language's canonical formatter) as part of every edit. Canonical formatting means every reader and every diff sees the same shape.” Here: the charge / save / deliver semicolon line becomes three lines.
billing · two calls to try
two_calls_to_try.cr
1# Idiom: one operation, understood on sight.
2late_fee_rate_from_the_contract = contract_terms["late_fee_rate"]?.try(&.to_f64?)
3
4# Puzzle: a lookup, a block binding, and a test, inside a negated guard.
5return unless invoice.customer.try { |c| c.list_of_all_active_subscriptions.any?(&.active?) }
Both lines call try. Only one of them reads.

The shorthand boundary

Short is not the problem. Three operations hiding behind short is the problem.

This is not a rule against short code. The conventions are blunt about it: “AED is 'clarity first,' not 'verbose always.'… The test is always: does a reader who has never seen this code understand the intent on first pass? If yes, keep it. If they have to mentally execute it, expand it.” Idioms that pass stay: arr.map(&.name), value.try(&.to_i64?), a ?-suffixed predicate, a single well-named guard expression.

Which is why the same method can be kept in one place and expanded in another.

Both lines call try. The first hands it one named conversion and reads as a sentence. The second asks you to execute it in your head before you know what it is for. The rule is about how many operations hide behind the shorthand, not about the shorthand.

The check that runs on the edit

Run the type check on the edit, not on the build ten edits later.

Readability is half the practice. The correctness half is edit-time verification: “run the compiler's type check (for Crystal, the compiler frontend with --no-codegen) on every edited file so mistakes surface immediately, not minutes later in a full build… AED keeps the code clear; the edit-time check keeps it compiling.”

The timing is the whole point. Run it on save and the error arrives while the edit is still the only thing in your head, or the only thing in your agent's context. Run it at the end of a build and it arrives after ten more edits have been stacked on top of it, and somebody has to work out which one broke.

zsh · your machine
$ crystal build --no-codegen src/billing/apply_late_fee.cr
Error: case is not exhaustive. Missing types: Grant::Base
Read https://agentc.consulting/agent-enhanced-development/chapter-6/edit-level-style.md and apply chapter 6 to my next edit.

The first two lines are the check and the answer the before pane earns. The third is the chapter command, and the copy button on the terminal copies only that line: paste it to your agent and it reads this machine edition, then applies the six rules to the next edit it makes for you. In our own tooling the type check runs automatically after every agent edit; wire the equivalent into whatever harness your agents use.

Copy the chapter command and hand it to your agent.

The gold outline is the context window; the tiles it slides along are tokens.

Why it matters to the agent

What survives inside one window is what you wrote into the lines themselves.

A model reads code through a sliding token window, and when it opens this file weeks from now it will usually see the changed method without the file around it. A named intermediate, an explicit type question, and a comment that gives a reason all survive. A chain, a ternary, and three statements crushed onto one line survive only for a reader who already knows the answer, which the next reader, by definition, does not. Chapter 1 covers the token window in detail.

The conventions repository publishes two pieces of evidence for this, and both state their own limits. In a comprehension benchmark run on 2026-07-07, Claude Haiku answered blind probes on ten pairs of Crystal snippets; the AED variants scored 60 of 60 and the conventional variants 54 of 60. The gap sat exactly where the argument predicts: modification probes tied at 20/20, while conventional lost points on intent (18/20) and defect-finding (16/20). The report calls that “a directional signal consistent with the hypothesis, not proof of it”: one small model, one run, ten pairs, a model grader.

The build-off of 2026-08-11 is the codebase-scale companion. The same task, a pet tracker, was given to fresh headless Haiku agents twice in a codebase before AED adoption and twice after. Both before-runs type-checked green and delivered zero of five working user journeys; both independently wrote the same data-corrupting serialization. The after-runs delivered two and four. The after arm cost more, because re-reading context across more turns costs more.

aed-conventions · rule 2
oauth_callback.cr
1# Terse, but the reader has to hold three operations in their head.
2return unless session["oauth_state"]?.try { |s| constant_time_equal?(s, state) }
3
4# AED
5expected_state = session["oauth_state"]?
6return unless expected_state && constant_time_equal?(expected_state, state)
Rule 2 twice over: the terse line on top, the named value below it.

Before and after, on an OAuth callback

Rule 2 is illustrated with an OAuth callback, not with billing.

That example checks the state parameter the callback issued at the start of the flow, which is exactly where a reader has to verify intent at a glance. It is the one place OAuth enters this book.

One note on that name. expected_state clears rule 4, since it is not tmp or x, but by chapter 3's naming rules it is still under-specified: expected by whom, of what? Chapter 3 is the authority where the two disagree, and the source repository records the conflict itself. That ordering holds generally: this chapter tightens a line, it never overrules a name.

The last read before done

The last five minutes of an edit are a read, not a rewrite.

Six boxes, from the checklist in the conventions.

  • Type branches use explicit if … is_a? (not case … in against Grant types).
  • No one-liner hides more than one operation from the reader.
  • Names state intent; no tmp, x, res2.
  • Comments explain why, never restate what.
  • crystal tool format is clean.
  • The edit-time type check passed (no case is not exhaustive, no undefined methods).

Reading with an agent? Hand it the machine edition of this chapter. The source is chapter 05 of the aed-conventions repository.