# Chapter 7 · Control flow

Machine edition of https://agentc.consulting/agent-enhanced-development/chapter-7/control-flow
Part of Agent-Enhanced Development: https://agentc.consulting/agent-enhanced-development.md
Source: chapter 06 of the AED conventions repository, https://github.com/AgentC-Consulting/aed-conventions/blob/main/06_control_flow.md

## The moment

You open the billing file your agent wrote and you cannot find the decision.
Which late fee applies is spread across a negated compound condition at the top,
a ternary nested inside another ternary in the middle, and a loop whose end you
have to simulate in your head before you can trust it stops. The decision is all
there; it is just not anywhere you can point at. This chapter puts the same
decision on one screen as a menu you read one line at a time, and nobody
rewrites the business logic.

## The menu: one decision, one line per choice

Control flow is the one place where AED's usual advice runs out. `while`,
`rescue`, `case`, `spawn` are mechanism keywords: they say how execution moves,
never why. No re-spelling makes `while @i < @lines.size` state an intent. So for
control flow the conventions shift their answer: **when the syntax can't read
like a statement, the names around it must say what the syntax is doing.**

How the menu reads.

1. One panel, the decision that used to be scattered, now on a single screen:
   the guard block of `perform`, then the fee menu itself.
2. Every line in that panel is a row you can hover, tap, or reach with the
   keyboard. There are six.
3. Hovering a row swaps that one line for the mechanism it replaced. Nothing
   else on the page moves. You are reading before and after one branch at a
   time, not flipping between two files.
4. The last row is the `else`. It replaced nothing, because in the original
   there was no line for the unlisted world at all.
5. Below the panel, two editor windows side by side carry Part B: the loop and
   the chain, before on the left, after on the right.

**The action: hover or tap every branch.** Six rows, six mechanisms.

The panel, one decision on one screen:

```crystal
def perform
  # Guards first, story second: nothing below runs on an invoice we do not charge.
  return unless the_invoice_is_chargeable?
  return if how_many_days_late < grace_period_in_days

  charge_the_late_fee_on_every_taxable_line_item
  tell_the_customer
end

# The menu. One line per plan, an explicit fallback for the unlisted world.
private def late_fee_rate_for_the_plan : Float64
  plan_name = currently_active_subscription.try(&.plan_name) || "standard"
  case plan_name
  when "enterprise" then 0.02
  when "pro"        then 0.035
  when "standard"   then 0.05
  else                   raise Billing::UnknownPlanError.new("No late fee rate for plan: #{plan_name}")
  end
end
```

What each row replaced:

- `return unless the_invoice_is_chargeable?` replaced a three-term `unless`
  joined by `&&` and broken across two lines. The reader had to run De Morgan
  to find out when it fired.
- `return if how_many_days_late < grace_period_in_days` replaced the third
  term of that same compound, the one that was actually company policy and had
  no name of its own.
- `when "enterprise" then 0.02` replaced the outer arm of the nested ternary.
- `when "pro" then 0.035` replaced the inner arm, the one inside the
  parentheses.
- `when "standard" then 0.05` replaced the final `: 0.05`, which was the
  default and was never called that.
- `else raise Billing::UnknownPlanError` replaced nothing. The old code
  charged five percent to any plan it had never heard of, silently. This row is
  the chapter's whole argument in one line: Crystal's `when` is not exhaustive,
  so the `else` is where you *say* what happens to the world you did not list.

In chapter 3 the late fee was a single property, `late_fee_rate : Float64 =
0.05`. It is a table now, because the company grew a fee table. The menu is
where a table belongs.

## Plain: loops and chains that state their finish line

The other two mechanisms in the file were a loop and a chain, and both had the
same defect: no finish line you could point at.

Before, the loop counted and the chain guessed:

```crystal
plan = @invoice.customer.subscriptions.select(&.active?).map(&.plan_name).first?.try(&.downcase) || ""

i = 0
until i >= @invoice.line_items.size
  item = @invoice.line_items[i]
  if item.taxable?
    @invoice.charge(item.amount * rate, kind: :late_fee)
  end
  i += 1
end
```

After, both say out loud what they are walking and what they are holding:

```crystal
# The finish line is the list of taxable line items, and it is said out loud.
private def charge_the_late_fee_on_every_taxable_line_item
  list_of_taxable_line_items = invoice.line_items.select(&.taxable?)
  late_fee_rate = late_fee_rate_for_the_plan

  list_of_taxable_line_items.each do |line_item|
    invoice.charge(line_item.amount * late_fee_rate, kind: :late_fee)
  end
end

private def currently_active_subscription : Subscription?
  invoice.customer.list_of_all_active_subscriptions.first?
end
```

Two things changed and both are naming, not logic. The finish line became a
thing you can point at: the loop walks `list_of_taxable_line_items`, so
termination is a fact about a list rather than arithmetic you verify by reading
`i += 1` and believing it. And the four-link chain became a waypoint with a
name. The waypoint is named for *what the data is* at that point
(`list_of_taxable_line_items`, `currently_active_subscription`), never for the
operation that produced it. `filtered`, `mapped`, `result2` tell the next reader
what you typed; they never tell them what they are holding.

One `.try` survives, in `currently_active_subscription.try(&.plan_name) ||
"standard"`. One is the cap. The old line had a `.try` feeding a `||` feeding a
`.downcase`, which is three nil-branches hidden inside a noun.

## The three readers

- **The developer** wrote one menu, one guard block, and one walk over a named
  list. No new behavior, no new tests, nothing renamed that the compiler did not
  confirm.
- **The owner** reads the late-fee table as the company's fee table, and reads
  the guard block as the "refuses when" list: it refuses when the invoice has no
  billable customer, and it refuses inside the grace period.
- **The agent** reads both, and writes the next branch the same way. When the
  company adds a plan, the change is one row in the menu, and the row is in the
  same window as the rest of the decision.

## The rules this chapter settles

- **CF-1 · "Case is a menu, not a novel."** Use `case … when` when branching a
  single named value across three or more outcomes. Two outcomes is an
  `if/else`: a menu of two is just a choice. Each `when` body is at most about
  three lines or a single named method call; if a branch needs a paragraph,
  extract it to a method whose name is the branch's intent. The `case` subject
  must be a named local or a plain method call, never an inline expression
  chain. Always write an explicit `else` that states the fallback: `false`, a
  raise, or a named default.
- **CF-2 · "Every loop names its finish line and shows its step."** Three
  blessed loop shapes and nothing else: the collection walk, the cursor loop
  over an explicit cursor that visibly advances, and the forever loop, which may
  only live inside a method whose name says it loops. If a loop body exceeds
  about eight lines, extract the body to a method named for one iteration's
  intent, so the loop line reads "while there are lines, consume the next
  block." `until` is banned: `until done?` forces the reader to negate in their
  head; write `while more?`.
- **CF-3 · "Guards first, story second."** All precondition guards sit in a
  contiguous block at the top of the method, before the first line of the happy
  path. One guard per line, one idea per guard.
- **CF-4 · "`unless` takes one idea."** `unless` may wrap one positive
  condition: a single predicate call, a single comparison, or a single presence
  check. Never `unless` with `&&`, `||`, or `!`; never `unless … else`.
- **CF-5 · "Ternary is a value with two spellings, and it gets a name."** Both
  arms simple values, no nesting, no side effects, and the result immediately
  named. Choosing between two actions is always an `if/else`.
- **CF-6 · "Two links spoken, three links named."** Three or more links, or any
  multi-line block mid-chain, gets named waypoints: intermediate variables named
  for what the data is at that point (`active_users`, `member_emails`), not for
  the operation (`filtered`, `mapped`, `result2`). One `.try` maximum per
  expression.
- **CF-7 · "Raise nouns, rescue by name."** Raising is always a typed error from
  the domain vocabulary; rescuing names the narrowest type that states what you
  forgive. A bare `rescue` with no binding and no comment is banned. A broad
  `rescue ex` is permitted only at boundaries, and must bind, log, and carry a
  why comment.
- **CF-8 · "Two operators is a sentence; three is a method."** A condition may
  contain at most two boolean operators, and never a mix of `&&` and `||`
  without extraction. The extracted name must be the positive form of the
  question, never `not_invalid?`.
- **CF-10 · "The suffix is a promise."** `def foo?` returns `Bool`, full stop,
  with one blessed exception: the stdlib maybe-lookup convention, where `?`
  means nil instead of raising. `def foo!` means it raises where `foo` returns
  nil, or that it mutates the receiver, and its doc comment states which danger
  it warns about.

Chapter 06 of the conventions also fences concurrency (CF-9, every fiber gets a
job title) and macros (CF-11, compile-time branches speak for both worlds); the
billing example does not exercise either, so they are left to the source
repository. And one honest line: chapter 06 ships as a release candidate. The
rules hold; the exact thresholds (the three-branch floor, the eight-line loop
body, the one-`.try` cap) are still under review, and the repository lists the
open questions.

## Why it matters to the agent

A model reads code through a sliding token window. A `case` on a named local
puts the whole decision inside one window: the subject, every branch, and the
fallback are adjacent text. A nested ternary puts the decision somewhere else
entirely: in operator precedence, which lives in the language specification and
not in the file. The agent can only recover it by parsing, and a small model
parsing is a small model not reasoning.

The same is true of the guard block. `return unless the_invoice_is_chargeable?`
carries its meaning in the window because the name is in the window. A three-term
`unless` carries its meaning in De Morgan's laws.

Then the payoff the conventions care about most: these names are the source of
documentation headings. Guard blocks become "Refuses when…", one bullet per
guard line, with the guard's why comment as the explanation. Case menus become
decision tables: the subject is the title, the `when` labels are the rows, and
the mandatory `else` is the documented fallback row. Typed errors become "What
can go wrong": the error subclass tree is the failure catalog. `?` predicates
become a glossary of business rules. What does not surface is deliberate:
ternary values, waypoint locals, and cursor predicates stay local, because the
selection rule is mechanical: a control-flow name surfaces to docs if and only
if it is a method name. Which means **the linter's tokens and the doc
generator's tokens are the same tokens.**

### Sources for this section

The evidence behind this chapter is modest and worth stating plainly. In the
Haiku comprehension benchmark (2026-07-07), AED-style Crystal scored 60 of 60
and conventional compressed style scored 54 of 60 across ten snippet pairs,
answered blind by Claude Haiku and graded blind; the losses concentrated in
defect-finding and intent probes, while modification probes tied. We call that
a directional signal on ten pairs and a single run, not proof. At
codebase scale, the pet-tracker build-off (2026-08-11) ran the same task twice
per arm on the same template: the two runs without AED delivered 0 and 0 working
user journeys out of five, and the two runs with AED delivered 2 and 4. All four
runs type-checked clean, which is the point: the difference was not compilation.

The census of our own code is the other honest number. Across the two live
codebases, `.try` appears 98 times in the template, `unless` 175 times, typed
raises outnumber string raises 138 to 14, and `until` appears zero times. The
rules target what is actually written.

## Before and after: the whole file

On the page these two are one editor window with two tabs. Before, the file as
it was:

```crystal
class LateFeeService
  def initialize(invoice, days_late)
    @invoice = invoice
    @days_late = days_late
  end

  def process
    unless @invoice.customer && @invoice.customer.subscriptions.size > 0 &&
           @days_late >= 30
      return
    end

    plan = @invoice.customer.subscriptions.select(&.active?).map(&.plan_name).first?.try(&.downcase) || ""
    rate = plan == "enterprise" ? 0.02 : (plan == "pro" ? 0.035 : 0.05)

    i = 0
    until i >= @invoice.line_items.size
      item = @invoice.line_items[i]
      if item.taxable?
        @invoice.charge(item.amount * rate, kind: :late_fee)
      end
      i += 1
    end

    begin
      CustomerMailer.late_fee(@invoice).deliver
    rescue
      nil
    end
  end
end
```

After, the same file as it reads now:

```crystal
# New domain vocabulary, minted under the module's base error, per CF-7.
class Billing::UnknownPlanError < Billing::Error; end

# Applies a late fee to an invoice that has gone past its grace period, then tells the customer.
class Billing::ApplyLateFee
  # -- What this needs --

  # The invoice being charged. It knows its customer, its line items, and its amount.
  property invoice : Invoice

  # How many days past the due date the invoice is today.
  property how_many_days_late : Int32 = 0

  # -- Company policy --

  # Days past due before any fee applies.
  property grace_period_in_days : Int32 = 30

  # -- How it runs --

  def perform
    # Guards first, story second: nothing below runs on an invoice we do not charge.
    return unless the_invoice_is_chargeable?
    return if how_many_days_late < grace_period_in_days

    charge_the_late_fee_on_every_taxable_line_item
    tell_the_customer
  end

  # One question, asked once, instead of three conditions in a negated guard.
  private def the_invoice_is_chargeable? : Bool
    return false unless invoice.has_a_billable_customer?
    currently_active_subscription != nil
  end

  # The menu. One line per plan, an explicit fallback for the unlisted world.
  private def late_fee_rate_for_the_plan : Float64
    plan_name = currently_active_subscription.try(&.plan_name) || "standard"
    case plan_name
    when "enterprise" then 0.02
    when "pro"        then 0.035
    when "standard"   then 0.05
    else                   raise Billing::UnknownPlanError.new("No late fee rate for plan: #{plan_name}")
    end
  end

  # The finish line is the list of taxable line items, and it is said out loud.
  private def charge_the_late_fee_on_every_taxable_line_item
    list_of_taxable_line_items = invoice.line_items.select(&.taxable?)
    late_fee_rate = late_fee_rate_for_the_plan

    list_of_taxable_line_items.each do |line_item|
      invoice.charge(line_item.amount * late_fee_rate, kind: :late_fee)
    end
  end

  # The rescue states exactly what it forgives, and why.
  private def tell_the_customer
    CustomerMail.late_fee(invoice).deliver
  rescue ex : Mail::DeliveryError
    # Boundary: a bounced notice must not undo a fee we already charged.
    Log.warn(exception: ex) { "Late-fee notice not delivered for invoice #{invoice.id}" }
  end

  private def currently_active_subscription : Subscription?
    invoice.customer.list_of_all_active_subscriptions.first?
  end
end
```

## Next

Next: Chapter 8 · How the workflow runs (the conventions stop being a style
guide and become the loop a team and its agents actually run).
