The bug was in the requirements, and three people implemented it faithfully
Correct code against a wrong spec passes every test.
TL;DR
A discount rule was specified, implemented, reviewed, tested and shipped. Then finance found that some orders had been discounted twice.
The code matched the spec exactly. The spec was internally inconsistent: rule 2 said a loyalty discount "applies to the order total", rule 4 said a promo code "applies to the order total", and nothing said whether "the order total" meant before or after another discount. Three engineers read it, each resolved the ambiguity silently and differently, and the one who wrote the shared helper resolved it a fourth way.
Specs can be wrong, and the cheapest check is a worked example per branch. Fifteen minutes of arithmetic on the requirement would have exposed it before anyone wrote code.
The case this post is about
The requirement, as written:
## Discounting
1. A customer may have at most one loyalty tier (bronze 5%, silver 10%, gold 15%).
2. The loyalty discount applies to the order total.
3. A customer may apply one promo code per order.
4. The promo code discount applies to the order total.
5. Shipping is free on orders over £50.
6. Discounts do not apply to shipping.
Six rules, each clear in isolation. The implementation:
fun priceOrder(order: Order, customer: Customer, promo: Promo?): Priced {
val subtotal = order.lines.sumOf { it.unitPrice * it.qty }
val loyalty = subtotal * customer.tier.rate // rule 2: of the total
val promoAmount = promo?.let { subtotal * it.rate } ?: 0 // rule 4: of the total
val total = subtotal - loyalty - promoAmount
val shipping = if (total > 5000) 0 else 499 // rule 5: of... which total?
return Priced(subtotal, loyalty, promoAmount, shipping, total + shipping)
}
The symptom
Orders discounted more than the business intended:
order subtotal £100.00
gold loyalty (15%) −£15.00
promo code (20%) −£20.00
total £65.00
Finance expected £68.00 — 20% off, then 15% off the reduced amount (or the other way; they had a view and it was not in the document).
stacked (as built): 100 × (1 − 0.15 − 0.20) = £65.00
sequential: 100 × 0.80 × 0.85 = £68.00
difference per order: £3.00
Three implementations in one codebase, from the same document:
pricing service (the one above): subtotal × (1 − a − b) stacked
mobile client's price preview: subtotal × (1−a) × (1−b) sequential
the admin refund calculator: subtotal − max(a, b) best-of-one
Three answers for the same order, visible to customers as a price that changed between the cart preview and the confirmation.
And the shipping threshold was ambiguous too:
subtotal £52, discounts £8 → total £44
"orders over £50": free shipping (subtotal) or £4.99 (total)?
pricing service: charged £4.99
marketing email: "free shipping over £50" — customers read it as subtotal
The cost:
orders priced with stacked discounts, 7 weeks: 8,420
average over-discount: £2.80
total: £23,576
plus: 310 support contacts about the price changing between screens
Why the obvious fix failed
"Fix the pricing service to match finance's expectation." Which expectation? Finance wanted sequential. Marketing had told customers that promo codes are "20% off" without qualification, and under sequential ordering a promo applied after loyalty is not 20% off the listed price. Picking one implementation without resolving the rule re-creates the problem next quarter.
"Make the three implementations consistent." Necessary and it is the second step. Making three wrong implementations agree produces one consistently wrong price.
"Add tests for the combined case." We had tests. Each one asserted the behaviour the author had implemented, so the tests encoded three different readings and all passed. A test written from the same ambiguous spec inherits the ambiguity.
"Blame the spec and ask product to rewrite it." Fair and insufficient: the document would come back with the same gap, because the gap is not obvious from reading. Something has to make the ambiguity visible, and prose does not.
The actual mechanism
The spec was internally inconsistent rather than unclear, which is the harder failure:
Each rule is individually unambiguous. "The loyalty discount applies to the order total" is a clear sentence. So is rule 4.
The interaction is unspecified. Both rules claim to apply to "the order total", and if one applies first, the other's base has changed. The document defines a value in terms of itself.
And an unspecified interaction is resolved silently. An engineer implementing rule 2 and rule 4 must choose an order. There is nothing to ask about — each rule is clear — so the choice feels like an implementation detail rather than a product decision, and it is not written down anywhere to compare.
rules: individually clear ✓
interactions: 1 pair unspecified ✗ → 3 implementations, 3 answers
The multiplication is the real problem: N rules that each "apply to the total" produce N! orderings, and a document listing rules does nothing to surface them.

A spec can be wrong by being incomplete about interactions, and prose hides that. Rules read as clear individually and the combination is undefined — which is resolved silently, by whoever writes the code.
The fix
A worked example per branch, agreed before building, and the rule expressed as a table rather than as sentences.
1. The worked examples, which is the whole intervention:
## Discounting — worked examples (agreed with finance + marketing, 2026-11-17)
| # | subtotal | loyalty | promo | shipping | expected total | note |
|---|----------|---------|-------|----------|----------------|------|
| 1 | £100.00 | gold 15%| — | free | £85.00 | single discount |
| 2 | £100.00 | — | 20% | free | £80.00 | single discount |
| 3 | £100.00 | gold 15%| 20% | free | £68.00 | **promo first, then loyalty on the reduced amount** |
| 4 | £52.00 | bronze 5%| — | free | £49.40 | shipping uses the SUBTOTAL for the £50 threshold |
| 5 | £48.00 | — | — | £4.99 | £52.99 | below threshold |
| 6 | £100.00 | gold 15%| 20% | free | £68.00 | discounts never apply to shipping |
Order of application: promo, then loyalty. Rationale: a promo code is a marketing
commitment on the listed price; loyalty is a customer benefit on what they pay.
Threshold basis: subtotal, matching the marketing copy.
Row 3 is the one that did not exist. Writing it required finance and marketing to agree in the room, which took eleven minutes and settled a £23,576 question.
2. The rule as a single function with an explicit order:
// one implementation. The order is a named decision, not an emergent property.
fun priceOrder(order: Order, customer: Customer, promo: Promo?): Priced {
val subtotal = order.lines.sumOf { it.unitPriceMinor * it.qty }
// 1. promo applies to the subtotal (a marketing commitment on the listed price)
val promoAmount = promo?.let { (subtotal * it.rate).roundHalfUp() } ?: 0L
val afterPromo = subtotal - promoAmount
// 2. loyalty applies to what remains
val loyaltyAmount = (afterPromo * customer.tier.rate).roundHalfUp()
val afterDiscounts = afterPromo - loyaltyAmount
// 3. shipping threshold uses the SUBTOTAL, per the marketing copy
val shipping = if (subtotal >= FREE_SHIPPING_THRESHOLD_MINOR) 0L else SHIPPING_MINOR
return Priced(subtotal, promoAmount, loyaltyAmount, shipping, afterDiscounts + shipping)
}
3. The worked examples as the test suite, so the spec and the tests are the same artifact:
// each row of the table, verbatim
private val CASES = listOf(
Case(1, subtotal = 10_000, tier = GOLD, promo = null, expected = 8_500),
Case(2, subtotal = 10_000, tier = NONE, promo = 0.20, expected = 8_000),
Case(3, subtotal = 10_000, tier = GOLD, promo = 0.20, expected = 6_800),
Case(4, subtotal = 5_200, tier = BRONZE, promo = null, expected = 4_940),
Case(5, subtotal = 4_800, tier = NONE, promo = null, expected = 5_299),
Case(6, subtotal = 10_000, tier = GOLD, promo = 0.20, expected = 6_800),
)
@Test fun matchesTheAgreedWorkedExamples() = CASES.forEach { c ->
assertEquals(c.expected, priceOrder(c.order(), c.customer(), c.promo()).totalMinor,
"case ${c.n}")
}
4. And one implementation, consumed by all three callers:
// the mobile preview and the admin refund calculator now call the same function
// via a shared module, so disagreement is not representable.
The practice, generalised:
## Spec review checklist (before estimating)
- [ ] Does any rule define a value in terms of another rule's output?
- [ ] For every pair of rules that could both apply: is the order specified?
- [ ] Is there a worked example for EACH combination, not just each rule?
- [ ] Do the numbers in the examples actually add up? (do the arithmetic)
The last item caught a second bug in a different spec the following month: a table of worked examples where row 4's total was £2 off, because the author had computed it in their head.
How we proved it
That the three implementations disagreed, enumerated rather than asserted:
order: £100 subtotal, gold tier, 20% promo
pricing service: £65.00
mobile preview: £68.00
refund calculator: £80.00 (best-of-one: only the promo)
Running the same input through all three is what turned "a pricing bug" into "three readings of one sentence".
That the worked-example suite catches each wrong reading:
implementation case 3 case 4 result
stacked (as shipped) £65.00 £49.40 FAILED case 3
sequential, loyalty first £68.00 £49.40 PASSED ← same total, different split
sequential, promo first £68.00 £49.40 PASSED
best-of-one £80.00 £49.40 FAILED case 3
threshold on total £68.00 £54.39 FAILED case 4
The second and third rows are worth noting: both produce £68.00 total, and they allocate the discount differently between the promo line and the loyalty line — which matters for the customer's receipt and for marketing's attribution. The agreed order resolves that, and the total alone would not have.
And the remediation:
affected orders: 8,420
over-discounted by: £23,576
decision: not reclaimed (customer-favourable; the error was ours)
marketing copy corrected: "20% off, applied before loyalty discount"
price-change support contacts: 310 → 2 over the following 7 weeks
Not reclaiming was a business decision, not a technical one, and it is the honest number: the company absorbed the cost of its own ambiguous spec.
The spec-review checklist, applied retroactively to six open specs:
specs reviewed: 6
with an unspecified rule interaction: 4
with arithmetic errors in their examples: 2
with no worked examples at all: 3
Four of six had the same class of gap, found in about twenty minutes each.
One limit: the checklist catches interactions between rules that are in the document. It does not catch a missing rule — and we found one of those later (what happens to a promo code on a partially refunded order), which no amount of reviewing the existing six rules would have surfaced. A worked example per scenario finance cares about, rather than per rule combination, would have; that is a harder discipline and we have not fully adopted it.
What this generalizes to
A spec can be wrong, and correct code against a wrong spec passes every test. Tests written from the same document inherit its ambiguities. Reviewing the requirement is a distinct activity from reviewing the implementation, and it is much cheaper.
Individually clear rules can be collectively undefined. The failure is in the interactions, and prose presents rules as a list rather than as a matrix. Any rule that operates on another rule's output needs an explicit order.
A worked example is the cheapest disambiguation there is. One table row — "£100, gold, 20% promo → £68.00" — settled a question that eleven weeks of implementation and review did not surface. Do the arithmetic; it is where the contradictions become visible.
Make the examples the tests. When the agreed table is the test suite, the spec cannot drift from the implementation, and a change to the rule is a change to a file both parties can read.
Related
- Idempotency is a product decision before it is a technical one — another product question mistaken for a technical one
- The architecture decision nobody wrote down, re-litigated four times — recording the rationale
- An estimate is a distribution, and you reported the mode — surfacing unknowns before building
Keep reading
This is part of 100 practices of senior engineers — the habits that separate five years of experience from one year five times. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".