On 1 April 2026 a new rate period switched on in the config. One number in it, a statutory payroll rate, had gone from 3% to 3.5%, and every calculator downstream kept producing the old answer. Not an error. No exception, nothing red. The number was right in the config and wrong everywhere it was used: the fraction never reached the arithmetic.
The constants for this feature live in YAML, loaded once at boot by frozen Ruby modules. That much was already true and already good: one authoritative list of valid rates. So the change was one new number, set in two YAML files, and I expected it to ripple outward for free. It didn't. A rails runner probe reading the value straight out of the rate-period module gave me 3, not 3.5. The YAML said 3.5. Somewhere between the file and the frozen constant, the half had gone missing. I didn't keep that output, but the question is easy to ask again. Asked again of the loader and the config exactly as they were committed, it gets the same answer (the scheme's name is generalised to "retirement" in the identifiers shown here and below):
$ ruby probe.rb loader-before.rb config.yml
period 2026-04-01 base_retirement_deduction
config file 3.5 (Float)
loaded value 3 (Integer)
It went missing in a normalisation routine that ran at module load time, just before the data was frozen:
def normalize_period(period)
# …
period[:student_loan_payment_threshold] = period[:student_loan_payment_threshold].to_f
period[:student_loan_payment_percentage] = period[:student_loan_payment_percentage].to_f
period[:student_loan_interest_overseas] = period[:student_loan_interest_overseas]&.to_f
period[:base_retirement_deduction] = period[:base_retirement_deduction].to_i
period[:max_allowed_retirement] = period[:max_allowed_retirement].to_i
# …
PERIODS = DATA[:periods].map { |p| deep_freeze(normalize_period(p)) }.freeze
A column of .to_f, with two .to_i in it. The fix was one letter, twice: both became .to_f, and the same probe against the fixed loader now reads 3.5 (Float) on both lines. Those two lines had been written in mid-February and had sat there for about six weeks, doing nothing. That is exactly why they were dangerous. 3.to_i == 3 is a no-op, so nothing broke: the routine "normalised" an integer to the same integer and everyone moved on. 3.5.to_i == 3 silently destroys the 0.5 and reverts the update. The coercion had never been correct; it had merely never met a value with a fractional part. The rate change didn't introduce the bug. It was the first input the bug had ever been handed that it could damage.
The obvious reaction, once you watch 3.5 become 3, is to make the value an integer again. I didn't, because the rate was right. The bug was that code in the loader, the views and the browser was coupled to the rate never having a decimal. That code wasn't coupled to the rate's value at all; it was coupled to its incidental type. The rate had always happened to be a whole number, and code all over the system had quietly written that coincidence into its assumptions. The change from integer to float hadn't created those assumptions. It had just found them.
Fixing the loader got the right number into the module. It did not get the right number onto the page, because the same assumption was baked in at the display layer too. Three view templates printed the rate through .to_f.round(0), the oldest of those lines dating from August 2023:
# before
… #{calculation_results[:retirement_perc].to_f.round(0)}% …
# after
… #{format('%g', calculation_results[:retirement_perc].to_f)}% …
For a whole-number rate that rounds to itself. For the new rate it rounded up to 4, because 3.5.round(0) == 4. That is worse than stale: 4 is a different rate, shown with confidence. Each of those became format('%g', …), which strips trailing zeros so 4.0 still reads as 4 but keeps a real fractional digit, so 3.5 stays 3.5.
Then the same mistake again, one layer down, in JavaScript. The Stimulus controllers read the rate select with parseInt:
// before
retirement_perc: this.hasRetirementSelectTarget ? parseInt(this.retirementSelectTarget.value) || 0 : 0,
// after
retirement_perc: this.hasRetirementSelectTarget ? parseFloat(this.retirementSelectTarget.value) || 0 : 0,
parseInt("3.5") === 3, so the moment the select offered 3.5, the browser would throw the half away and send 3 upstream. It was the same data loss as the Ruby .to_i, in a different language and a different file. A grep found that identical line in three controllers. Each became parseFloat.
By now I could see the shape of the thing. Every one of these was a coercion that agreed with the truth as long as the truth was a whole number and diverged the instant it wasn't. Putting the same two inputs through each coercion makes it stark:
| input | .to_i |
.to_f.round(0) |
format("%g") |
parseInt |
parseFloat |
|---|---|---|---|---|---|
3 |
3 | 3 | 3 | 3 | 3 |
3.5 |
3 | 4 | 3.5 | 3 | 3.5 |
The top row is why the bug hid: every column agrees, so every coercion looks correct, so nobody suspects any of them. The bottom row is the bug: two columns silently drop to 3, one silently jumps to 4, and only the widest coercions, format("%g") and parseFloat here and .to_f in the loader, tell the truth. In every case the safe choice was the one that preserved the most shape.
Try it: type a value
| coercion | result |
|---|---|
| .to_i | 3 ✗ |
| .to_f.round(0) | 4 ✗ |
| format("%g") | 3.5 |
| parseInt | 3 ✗ |
| parseFloat | 3.5 |
True value: 3.5. The JavaScript here mirrors the Ruby semantics; flagged cells diverge from the true value.
It is tempting to read this as a case for strict parsing. It isn't one. Ruby's Integer(), Python's int() and a PostgreSQL cast all refuse the string "3.5". Hand them the number instead, which is what a YAML loader hands you, and they narrow it without a word. Captured on Ruby 3.4.8, Python 3.11.4 and PostgreSQL 16:
$ ruby -e 'p Integer("3.5")'
<internal:kernel>:288:in 'Kernel#Integer': invalid value for Integer(): "3.5" (ArgumentError)
from -e:1:in '<main>'
$ ruby -e 'p Integer(3.5)'
3
$ python3 -c 'print(int(3.5))'
3
$ psql -X -d postgres -e -c 'SELECT 3.5::int;'
SELECT 3.5::int;
int4
------
4
(1 row)
Ruby and Python truncate; PostgreSQL rounds. A strict parser guards text. It does not guard a narrowing, so Integer() in the loader would have produced exactly the same 3. Strictness was never the fix. Width was.
The rate had been hardcoded across form select options, service constants, view and PDF templates, browser controllers, an FAQ schema and spec fixtures, so a one-rate change became a 65-file refactor: 64 files and a lockfile, in five commits made within just over an hour on the morning of 14 April. By kind, that was 19 services or props builders, 18 view templates, 11 specs or fixtures, six browser controllers and a short tail of everything else. Most of it was mechanical: delete the scattered copy, read from the one YAML. The interesting part was deciding what the flow between layers should look like once there genuinely was one source. What I landed on keeps no fallback copy of the number anywhere. The constants sit in YAML, loaded once by the frozen modules. A controller before_action exposes them as instance variables. The view renders those ivars into data attributes. The Stimulus controller reads the data attributes. Every layer reads from the layer immediately above, and no layer holds its own fallback copy. There is exactly one place the number lives, and everything else is a straw pointed at it.
The same shape turned up in the browser a second time, and that one I wrote myself, the same morning. At 09:23 I gave three Stimulus controllers sensible-looking defaults, ten of them, each a copy of a Ruby constant the server already held:
static values = {
defaultWeeks: { type: Number, default: 46 },
defaultHours: { type: Number, default: 40 },
defaultDays: { type: Number, default: 5 },
defaultExpensePercent: { type: Number, default: 15 }
}
That is the whole problem. A default: in a lower layer is the mirror image of a .to_i in the loader: both are silent bets that the data's shape will never change. The .to_i bet the value would stay whole; default: 46 bets the server's number will always be 46, and quietly serves 46 to any view that doesn't pass one. The day the server value changes you have two sources of truth and nothing to tell you they have diverged. A form that missed the wiring keeps rendering yesterday's number, confidently, with no error to chase. Twenty-seven minutes later I deleted all ten, leaving each value with nothing but its type. Another of the three controllers now says why in a comment:
// … No hardcoded
// defaults here so drift is impossible: if a view forgets to wire
// a value, the form will visibly break at 0 rather than silently
// using a stale fallback.
static values = {
defaultRetirementRate: Number,
contractorDefaultWeeks: Number,
contractorDefaultHours: Number,
contractorDefaultExpensePercent: Number
}
Now a view that forgets to wire a value renders zero, and zero is loud. A 0% rate is visibly broken, where a stale but plausible 46 would quietly pass for correct. Fail loud, not stale.
The float even reached into how results were named. One controller showed the effect of each rate in its own target, declared one per rate ("retirementResult3", "retirementResult4", "retirementResult6", "retirementResult8"), and looked the right one up by building its name from the rate itself:
const targetName = `retirementResult${rate}`
const capitalized = targetName.charAt(0).toUpperCase() + targetName.slice(1)
if (this[`has${capitalized}Target`]) {
A naming scheme like that assumes the rate is a whole number as firmly as .to_i does. The view hardcoded the same four rates as buttons, so 3.5 was not even on offer, and the controller read the chosen rate with parseInt. Once the buttons came from the YAML list and the rate was read with parseFloat, a 3.5 button would ask for a target called retirementResult3.5, a name with a full stop in it that nothing declared, so the has…Target check would come back undefined and the result would quietly never appear. The fix dropped the per-rate targets for a single retirementResult target, each element carrying its rate in a data-rate attribute and found by comparing parseFloat(el.dataset.rate) with the rate. The type change didn't just corrupt data; it broke a naming convention.
None of these were separate bugs. They were one assumption, "this number is a whole number", written into a loader, three display templates, three browser controllers and a target-naming scheme, then exposed all at once by a value that finally had a fraction in it. A .to_i in a config loader and a default: 46 in a Stimulus controller are the same mistake in two costumes: each silently bets the data's shape will never change, and each fails without a sound when the bet is wrong. The corrections rhyme too. Every normalisation is a data-type commitment, so make it the widest plausible one: .to_f over .to_i, parseFloat over parseInt, format("%g") over round(0), and an exact decimal rather than a float once the number is money. Every lower-layer default is a drift bomb, so don't hold one. Let the layer above be the only source, and let the failure be loud when the wiring breaks. The half that went missing was never really about the rate. It was the codebase telling me which of its coercions had been guessing all along.