All writing
Essay 18 Jun 2026 5 min read

A Credit Score That Follows You

How I made a credit score portable across lenders who share nothing — keyed on a phone number that never touches the database in the clear.

In the informal lending economy, reputation is trapped. A borrower who has repaid a savings group faithfully for three years starts from zero the moment she walks into a different lender. There is no shared bureau she trusts, no national ID she can rely on, and no lender willing to hand its ledger to a competitor. Her good name, painstakingly earned, is stranded inside whichever institution recorded it.

To free it, you need one thing before anything else: a way for two lenders who have never spoken to point at the same person and agree they mean the same person — with no central registry issuing IDs, and no fuzzy name-matching that will confuse two Nakatos in the same village.

The only identifier every party already holds is the phone number. It is universal, stable, recognisable. It is also exactly the kind of personal data you do not want sitting in plaintext in a database that might one day leak. So the identifier that makes the product possible is the identifier that makes it a liability. That is the whole problem in one sentence.

Why the obvious keys all fail

A raw phone number converges naturally but is a breach waiting to happen. A national ID fails on coverage — rural registration is patchy, and a regional product spans several ID schemes with no common format. An internal UUID with the phone kept as a private column just moves the plaintext behind a different name. And the textbook “secure” answer — a salted hash — quietly breaks the one property the product cannot live without.

Portability requires that any partner who knows the borrower’s phone can compute the same key independently. A salt makes that impossible: to reproduce the key, every partner would need the salt, and a secret shared with every partner is not a secret. The safe-looking choice destroys the product.

So the key is an unsalted SHA-256 of the E.164 phone number — deterministic, so the same phone always resolves to the same identity; one-way, so a leak exposes hashes, not numbers; and independently computable by anyone who already knows the phone. “Unsalted” here is a deliberate decision, not a lazy one, and I wrote it down as a decision record before writing a line of the identity layer — because the choice constrains every table and every API downstream of it.

The unsalted hash has a real weakness: the phone-number space is small enough to brute-force, so the hash is not cryptographic protection. I don’t pretend it is. Its job is to shrink the blast radius — a database dump leaks opaque keys, not a directory of everyone’s primary contact channel — and the actual security weight sits on a layer that has to be earned: column encryption, row-level security, audit-logged reads, and a rule that the hash never leaves the server in any response. Naming the accepted weakness and what pays for it is the honest version of the design. Hiding it would not make it go away.

A hard gate under a soft score

On top of that identity sits the score itself: a hard regulatory gate first — an unverified identity is rejected, never quietly handed a low number — then a set of independently-capped signals summed and mapped onto the 300–850 range lenders already trust. The gate and the continuous score are different jobs, and collapsing them produces a system that silently scores people it should have refused.

One bug worth keeping

The signals are gathered concurrently, and that is where a real trap lives. In development, Rails’ autoloader is not thread-safe: a constant that begins loading on one thread and finishes on another corrupts the namespace — an “uninitialized constant” that never reproduces under test and vanishes the moment you add a log line. You only learn that fault line from a production heisenbug, or from someone who already paid for it.

concurrent_rails_fanout.rb view on GitHub ↗

futures = legs.map do |leg|
  Concurrent::Future.execute do
    Rails.application.executor.wrap do             # per-thread autoloader + request context
      ActiveRecord::Base.connection_pool.with_connection do
        leg.call
      end
    end
  end
end
results = futures.map(&:value)   # each settles :ok or :failed — one bad leg never sinks the run

The lesson was that the bug was never about threads. It was about autoloading, and the fix is to establish the autoloader and a checked-out connection before any application code runs inside the future.

The tradeoffs I named

Honesty about the edges is the credibility. Recycled phone numbers would attach the wrong human, so an identity whose SIM tenure is shorter than its oldest transaction is flagged stale and forced through re-verification. Number changes sever continuity, and the chain that repairs them is deferred out of the first version rather than half-built. The 300–850 calibration is a stand-in fitted over a handful of archetypes — not trained on a real repayment population, and I don’t claim it is. A reviewer who finds these already annotated with the mitigation learns more about how I think than one who finds them hidden.


The build won first place at GSMA Africa Ignite 2026 — a solo effort across a Rails 8 API, a web app, and an Android client, in twelve days.

The three snippets behind this — a dependency-free streaming parser, the autoload-safe fan-out above, and a USSD state machine — are collected as a public gist. The full breakdown, with the decision records and the rejected alternatives, is the architecture case study →

This is the first of a series taking apart the systems behind the work.

The writer

Aheebwa Ramadhan is a software engineer of eight years and the founder of Badrama Technologies, a software studio building products that compound on shared infrastructure. He works on payments and messaging infrastructure in East Africa, and writes here when an idea has earned the time to be written well.

All writing