# Chapter 6 · Edit-level style

Machine edition of https://agentc.consulting/agent-enhanced-development/chapter-6/edit-level-style
Part of Agent-Enhanced Development: https://agentc.consulting/agent-enhanced-development.md
Source: chapter 05 of the aed-conventions repository, https://github.com/AgentC-Consulting/aed-conventions/blob/main/05_edit_level_style.md

## The moment

You have changed one method. The structure was settled chapters ago: 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 edit, before and after

The page shows three things, in this order:

1. An editor window in its two-pane form. 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.
2. Below the window, the six moves in the order the rules number them: what
   the before hid, and what the after states.
3. A terminal window carrying the check that runs on the edit, the error the
   left pane earns, and the chapter command. The copy button copies the
   command, and once it is copied the page says so.

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

Here is the left pane. The class, the properties, and `perform` are settled
work; every defect is inside the one private method.

```crystal
# BEFORE: one method, written for the author
class Billing::ApplyLateFee
  property invoice : Invoice
  property how_many_days_late : Int32 = 0
  property grace_period_in_days : Int32 = 30
  property late_fee_rate : Float64 = 0.05

  def perform
    charge_the_late_fee
  end

  private def charge_the_late_fee
    # charge the fee
    return unless invoice.customer.try { |c| c.list_of_all_active_subscriptions.any?(&.active?) }

    f = how_many_days_late < grace_period_in_days ? nil : (invoice.amount * late_fee_rate)
    return if f.nil?

    case invoice.customer
    in Customers::Enterprise then r = 0.0
    in Customers::Standard   then r = f
    end

    invoice.charge(r, kind: :late_fee); invoice.save; CustomerMail.late_fee(invoice).deliver
  end
end
```

And the right pane. Same class, same method name, same behavior.

```crystal
# AFTER: the same method, written for whoever reads it
class Billing::ApplyLateFee
  property invoice : Invoice
  property how_many_days_late : Int32 = 0
  property grace_period_in_days : Int32 = 30
  property late_fee_rate : Float64 = 0.05

  def perform
    charge_the_late_fee
  end

  # Enterprise agreements waive late fees by contract, so the type question is
  # asked out loud rather than hidden behind a zero rate.
  private def charge_the_late_fee
    currently_active_subscription = invoice.customer.currently_active_subscription
    return unless currently_active_subscription
    return if how_many_days_late < grace_period_in_days

    if invoice.customer.is_a?(Customers::Enterprise)
      record_that_the_late_fee_was_waived
    else
      charge_the_standard_late_fee
    end
  end

  private def charge_the_standard_late_fee
    late_fee_amount_owed = invoice.amount * late_fee_rate
    invoice.charge(late_fee_amount_owed, kind: :late_fee)
    invoice.save
    CustomerMail.late_fee(invoice).deliver
  end

  private def record_that_the_late_fee_was_waived
    invoice.note("Late fee waived under the enterprise agreement.")
  end
end
```

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.

**The chain.** `invoice.customer.try { |c| … }` 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.

**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.

**The names.** `f` and `r` carry their meaning only for the few minutes you
still remember writing them. `late_fee_amount_owed` carries it for everybody,
and the two branch methods spell out the decision so no comment has to.

**The comments.** `# charge the fee` restated the next line, so it is gone. The
comment that survives states the contractual reason enterprise invoices are
treated differently, the one thing the code genuinely cannot say.

**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.

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

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.

## The shorthand boundary

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.

```crystal
# Idiom: one operation, understood on sight.
late_fee_rate_from_the_contract = contract_terms["late_fee_rate"]?.try(&.to_f64?)

# Puzzle: a lookup, a block binding, and a test, inside a negated guard.
return unless invoice.customer.try { |c| c.list_of_all_active_subscriptions.any?(&.active?) }
```

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

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.

```text
$ 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.

## Why it matters to the agent

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. What survives inside one window is what was written into the lines
themselves. 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.

## Before and after, on an OAuth callback

The illustration of rule 2 in the conventions is not billing. It is an OAuth
callback checking the `state` parameter it issued at the start of the flow,
which is exactly where a reader has to verify intent at a glance.

```crystal
# Terse, but the reader has to hold three operations in their head.
return unless session["oauth_state"]?.try { |s| constant_time_equal?(s, state) }

# AED
expected_state = session["oauth_state"]?
return unless expected_state && constant_time_equal?(expected_state, state)
```

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).

Next: Chapter 7 · Control flow (the eleven rules for the syntax that has no
name of its own: loops, guards, rescues, and fibers. Drafted, and shipping as a
tagged minor version of the conventions).
