About

Two Correct Commits, One Silent Regression: Why a Rails Auto-Link Feature Died Three Months Later

The scope for attaching and the scope for finding drifted apart, and no single diff showed it. A working ActiveRecord reproduction and the round-trip spec that catches it

Some regressions stay invisible even if you read both PRs, because each PR is right. By the end of this you can hunt for the same drift in your own Rails app and write the one spec that stops it.

Short version: the scope you write with and the scope you read with need to match. Hand-write them separately and one will eventually narrow on its own. Code review won't catch that. A spec that writes and then reads back will.

"No candidates, for all of them"

A work project of mine (a contract management SaaS) links related contracts automatically. Upload a memorandum or an amendment PDF, AI reads which contract it refers to and when that was signed, and the system attaches it to the original as a child.

One day a trial customer wrote in. Dozens of documents uploaded that day had all come back with "no candidates." I opened the ones they mentioned. The signing dates matched. Document name similarity was comfortably above the auto-link threshold. The candidate list was still empty.

High similarity and zero candidates? That made no sense to me until I looked at the order of the checks.

1. Date        parent signed or start date matches   SQL
2. Company     partner looked up by ID, not name     SQL   no IDs: return nil
3. Similarity  document and file names compared      Ruby  expensive, so last

Cheapest filter first, the usual order. Stop at step 2 and step 3 never runs, however high the similarity would have been.

The two scopes

When a name isn't in the partner master, we create an "unconfirmed" partner from the OCR result and attach it to the contract. Boiled down, the code looked like this (names changed).

class Org < ApplicationRecord
  has_many :partners
  has_many :active_partners, -> { where(confirmed: true) }, class_name: 'Partner'
end

# Attach side: reuses unconfirmed rows to avoid duplicates
org.partners.find_or_create_by!(name: name)

# Match side: can't see unconfirmed rows
ids = org.active_partners.where(name: name).pluck(:id)
return nil if ids.empty?

An org that never registered a partner has only unconfirmed ones. The attach side sees the row and the match side doesn't, so ids is always empty and the chain ends at gate 2.

One table, two scopes, an org with no confirmed partnersAttach sideorg.partnersAcme unconfirmedsees it, reuses it, contract is savedMatch sideorg.active_partners[]confirmed only, so the row is invisibleThe gates run cheapest first, and zero rows stops the chain1. DatematchesSQL2. Partner IDs[] return nilSQL3. Name similaritynever runsRubyOrgs with confirmed partners pass gate 2, so the overall match count barely moves.That is why no dashboard noticed.

What threw me further was that the partner's name came through a different path and looked fine. Name present, ID missing. The logs read as healthy.

Why it took three months

It only made sense once I laid git log out in order.

mid-June        unconfirmed partners added, list screen filters on confirmed
26 min later    filter moved inside active_partners
                commit message: "contract matching unaffected"
~2 weeks later  unrelated refactor drops the name string fallback  <- regression complete
August          production release
early Sept      trial customer writes in

"Unaffected" was true when it was written, because the fallback that matched by name was still alive. The refactor two weeks later did nothing wrong either. The definition of active_partners isn't in that diff. It just looks like clearing away a path nobody needs.

I don't think any reviewer would have caught this in either PR. The first commit's safety rested on code that was deleted later, and that dependency was recorded in a commit message rather than in code.

Reproducing it

To check I had this right, I built a minimal model with ActiveRecord 8.0.4 and in-memory SQLite. Org A has a confirmed partner, org B has only an unconfirmed one. Attach one contract to each, then look it up.

                       A (confirmed)   B (unconfirmed only)
with the fallback       1               1
fallback removed        1               0   <- here
match scope fixed       1               1

Look at A alone and nothing broke. That's how it survived three months. Orgs with registered partners kept working, so the overall link count never dropped enough to notice. When we counted later, a bit over a third of orgs had zero confirmed partners. For those orgs, auto-linking had returned nothing since the release.

The fix was one line: make the match scope agree with the attach scope. One line to fix, three months to find.

Having two scopes wasn't the mistake

My first reaction was "why hide unconfirmed rows at all?" The numbers changed my mind. Close to 80% of the partner table was unconfirmed rows generated by OCR. Show those in autocomplete or the master screen and the tens of thousands people registered vanish under a hundred thousand or so the AI made. Hiding them from display was correct.

The mistake, I think, was calling a display scope active_partners. It reads as "the live partners," so of course you reach for it when querying. Had it been listable_partners, someone would have paused before using it in matching code.

The same shape was already piling up

Hundreds of groups in the table had the same partner name twice in one org, most of them mixing confirmed and unconfirmed. That's what a registration path leaves behind when it looks up through a relation that can't see unconfirmed rows and creates one if nothing turns up. This reproduces too.

org.partners.where(name: 'Acme').pluck(:id, :confirmed)
# => [[2, false]]

org.active_partners.find_or_create_by!(name: 'Acme')

org.partners.where(name: 'Acme').pluck(:id, :confirmed)
# => [[2, false], [3, true]]   now the name exists twice

Create through a scoped relation and the where condition becomes attribute values. So a new confirmed partner appears right next to the unconfirmed one it couldn't see. One name, a whole family of bugs.

Is there a word for this?

The closest is Martin Fowler's semantic conflict: changes that merge cleanly as text but make the program behave differently. His piece is about parallel branches, while ours landed in sequence two weeks apart. The answer is the same though. Tests catch it, not a reviewer's eyes.

What to use when

Option               Stops                         Cost          When
round-trip spec      this whole regression         one spec      start here
single match scope   the next hand-written copy    refactor      2+ places look it up
rename the scope     picking the wrong one         find/replace  the scope's meaning is narrow
zero-count per org   nothing, but you hear sooner  dashboard     any automatic feature
code review          not this kind of bug          -             don't rely on it

If you pick one, pick the round-trip spec. Write data with the real attach code, then read it with the real match code. Don't test the scope with a hand-built fixture. Fixtures tend to use confirmed partners, and then this exact bug passes.

it 'finds contracts attached through an unconfirmed partner' do
  org = create(:org)                       # zero confirmed partners
  ContractAttacher.call(org, 'Acme', date) # the real attach code

  expect(ParentFinder.call(org, 'Acme', date)).to be_present
end

Run this shape of check against the reproduction above and it fails on the version without the fallback.

What to do in your own code

# relations and scopes with conditions
grep -rnE "has_many .*-> *\{|scope :" app/models
# places that quietly bail on an empty result
grep -rnE "return( nil)? (if|unless) .*(empty\?|blank\?|none\?|any\?|present\?)" app

Where the two lists meet, look closer. If a display scope feeds matching, a duplicate check or find_or_create_by, give it a spec. And when you review a PR that deletes a fallback, ask first what relies on it.

Features that quietly go to zero, like this one, can't be watched through a grand total. Split it per org or you won't see it.

If you'd like a hand finding quiet regressions like this in a Rails app, or pinning them with specs, feel free to ask negabaro.

Key Points

1

Check that the code writing a row and the code finding it see the same scope. Written by hand twice, they will drift

2

In the spec, write through the real attach code and read through the real match code, not fixtures

3

Name display scopes for their purpose, something like listable_

4

When a PR removes a fallback, first find what relies on it

5

Watch features that can quietly go to zero per org, not as a total

Use Cases

Auditing matching and duplicate-check code on Rails models with several scopes or relations
← Faraday open_timeout: Leave It Unset and It Takes Your timeout