Harness 2026-11-01
AgentC Blog

You wrote the late-fee rule. What does it mean now?

A line in an old billing file makes you stop and ask what it means, and your coding agent has to ask the same thing with less to go on. Then we gave Claude one job in two versions of our app and had its work checked.

10 min read ~200ms if you're an agent Subscribe via RSS

Imagine opening an old billing file of yours to check a late-fee rule, because a customer thinks they were charged too early. The method is short. You find the line:

return unless overdue && days > 10

Ten what? Days since the invoice was sent, or since it was due? Who sets overdue, and can it be true at day three? You wrote this. You can't remember, so you read three more files to find out.

Now picture your coding agent opening the same file to fix the same complaint. It has the same question and less to go on. Does a clearer name change what the agent actually builds, or does it just give it longer words to read?

The file, as most of us write it

Here is the whole class, an example written for this post in the short style most of us use under a deadline:

module Billing
  class LateFee
    property inv : Invoice
    property overdue : Bool
    property fees : Array(Fee) = [] of Fee

    def initialize(@inv, @overdue)
    end

    def perform(days)
      return unless overdue && days > 10
      f = Fee.new(invoice_id: inv.id, amount_in_cents: inv.balance_in_cents * 15 // 1000)
      fees << f
    end
  end
end

We built a naming check, the AED plugin, that reads each file right after the agent edits it. This is what it reports for that file (real output, plugin v0.2.1):

billing/late_fee.cr:4: [AED-N2 warn] boolean attribute `overdue` does not read as a yes/no question — is_this_overdue / has_been_overdue
billing/late_fee.cr:5: [AED-N3 warn] enumerable attribute `fees` does not say it holds a collection — list_of_fees / collection_of_fees / array_of_fees
billing/late_fee.cr:10: [AED-N6 warn] `perform` in `LateFee` takes arguments; a process manager receives everything it needs in `initialize` — move these arguments into `initialize` and leave `def perform` with no parameters
billing/late_fee.cr:12: [AED-N1 warn] local variable `f` does not say what it holds — name it as a short statement of its purpose, e.g. `customer_record_to_update`

The same rule, said out loud

Here is the class rewritten with the conventions, in billing/apply_late_fee_to_overdue_invoice.cr:

module Billing
  # When an invoice is still unpaid after its grace period, then add one late fee
  # of 1.5% of its balance.
  class ApplyLateFeeToOverdueInvoice
    GRACE_PERIOD_IN_DAYS = 10

    property invoice_that_is_past_due : Invoice
    property is_this_invoice_overdue : Bool
    property number_of_days_past_due : Int32
    property list_of_fees_added : Array(Fee) = [] of Fee

    def initialize(@invoice_that_is_past_due, @is_this_invoice_overdue, @number_of_days_past_due)
    end

    def perform : Nil
      return unless is_this_invoice_overdue && has_passed_its_grace_period?
      add_one_late_fee_to_the_invoice
    end

    private def has_passed_its_grace_period? : Bool
      number_of_days_past_due > GRACE_PERIOD_IN_DAYS
    end

    private def add_one_late_fee_to_the_invoice : Nil
      late_fee_in_cents = invoice_that_is_past_due.balance_in_cents * 15 // 1000
      list_of_fees_added << Fee.new(invoice_id: invoice_that_is_past_due.id, amount_in_cents: late_fee_in_cents)
    end
  end
end

The 10 now has a name, GRACE_PERIOD_IN_DAYS. The check reads as a sentence: return unless is_this_invoice_overdue && has_passed_its_grace_period?. The comment on top is the rule in plain words, so the reason for the code sits right above it. The overdue flag is still there, now named as the yes-or-no question it answers, is_this_invoice_overdue. The naming check reports nothing on this version. It never flagged inv, which we renamed anyway; it catches the common cases, not all of them.

What a small model did with each version

We wanted to see this at the size of a real app, so we ran a small experiment on a different job. We gave Claude Haiku, a small and fast model, the same task in two versions of our open-source app template: build a pet tracker that lists, shows, adds, edits and deletes pets, following the patterns already in the code.

A larger model then read every line each try wrote and judged which of those five things would work. In the version with short names, it built the tracker twice, and both times the reviewer found none of the five would work: in one, the pet list would come up silently empty; in the other, the page would crash as soon as a pet existed. In the version with the renamed code, plus the naming section in CLAUDE.md and the naming check, the reviewer judged two of the five workable in one try and four in the other. The short-name tries were cheaper: 37 cents less across both, at API prices.

That is two tries a side on one task, judged from the code rather than by clicking through, and the renamed version also had extra instructions and the check, so the names alone don't get all the credit. Even the best try had a serious bug, described below. A smaller snippet test, below, also favored the descriptive versions. It is a demonstration, not proof.

Try this today

Open a file you or your agent changed this week and find the one name you'd have to ask about, like days, data or f. Ask Claude Code: "Rename days to say what it counts, and update every place that uses it." Read the diff; if the new name makes a line read like a sentence, you have felt the whole idea.

Step 2 of the lesson installs the naming check, which reports names like these after every edit and never blocks one, and chapter 3 of the AED book has the rules in full.

Code gets written once and read every time someone, or something, has to change it.

billing/late_fee.cr4 findings
1 module Billing
2 class LateFee
3 property inv : Invoice
N2 not a question 4 property overdue : Bool
N3 not a list 5 property fees : Array(Fee) = [] of Fee
6
7 def initialize(@inv, @overdue)
8 end
9
N6 perform takes args 10 def perform(days)
11 return unless overdue && days > 10
N1 says nothing 12 f = Fee.new(invoice_id: inv.id, amount_in_cents: inv.balance_in_cents * 15 // 1000)
13 fees << f
14 end
15 end
16 end
billing/apply_late_fee_to_overdue_invoice.cr0 findings
1 module Billing
2 # When an invoice is still unpaid after its grace period, then add one late fee
3 # of 1.5% of its balance.
4 class ApplyLateFeeToOverdueInvoice
5 GRACE_PERIOD_IN_DAYS = 10
6
7 property invoice_that_is_past_due : Invoice
8 property is_this_invoice_overdue : Bool
9 property number_of_days_past_due : Int32
10 property list_of_fees_added : Array(Fee) = [] of Fee
11
12 def initialize(@invoice_that_is_past_due, @is_this_invoice_overdue, @number_of_days_past_due)
13 end
14
15 def perform : Nil
16 return unless is_this_invoice_overdue && has_passed_its_grace_period?
17 add_one_late_fee_to_the_invoice
18 end
19
20 private def has_passed_its_grace_period? : Bool
21 number_of_days_past_due > GRACE_PERIOD_IN_DAYS
22 end
23
24 private def add_one_late_fee_to_the_invoice : Nil
25 late_fee_in_cents = invoice_that_is_past_due.balance_in_cents * 15 // 1000
26 list_of_fees_added << Fee.new(invoice_id: invoice_that_is_past_due.id, amount_in_cents: late_fee_in_cents)
27 end
28 end
29 end

How we tested it

  • The naming check output: aed_lint.rb from the AED plugin at plugin-v0.2.1, run on November 1, 2026 on the two example files above. The examples were written for this post.
  • The pet tracker: August 11, 2026. Claude Haiku in headless Claude Code, fresh each run, two runs per version, one machine. Version one was our template before the renames, with no naming section and the plugin off. Version two was the same code after a renames-only pass (243 findings fixed, every spec still passing), plus the naming section in CLAUDE.md and the advisory check. The task told the agents not to run migrations, tests or the server, so nothing was clicked through: a larger model reviewed every try's code against the same rubric and judged the five user actions 0 and 0 workable before, against 2 and 4 after. In the 2-of-5 try, missing form security tokens blocked adding a pet, and edit and delete relied on a form trick the framework doesn't support. In the 4-of-5 try, clicking Delete would silently update the pet and report success. Cost per try: $0.50 and $0.38 against $0.61 and $0.64; most of the extra cost was the model rereading context over more turns. All four tries passed the type check. The data is public.
  • The snippet test: Claude Haiku answered questions about 10 pairs of Crystal snippets that behave the same, one with descriptive names and one compact. The descriptive versions also had fuller control flow and comments about intent, so this test can't isolate naming either. A second model graded the answers. Descriptive versions scored 60 of 60, compact versions 54 of 60. Seven of the ten pairs tied; the gap came from three, mostly in finding bugs and explaining intent. We wrote the snippets and the questions ourselves. One run, one model family (report).
  • Limits: two runs a side, one task, one codebase, one model. The two versions differ by the names, the instructions and the check together, so we can't say how much each part did.

Next in the series: We wrote the lesson down twice, and the mistake kept happening