Engineering

The dependency with one maintainer, and the week it went away

Bus factor is a property of your supply chain, not just your team.

MobileByteSensei 9 min read
practicesarchitectureoperations

TL;DR

A 340-line library that formatted phone numbers was pulled from its registry by its sole maintainer, after a dispute. Our builds broke in eleven repositories within an hour — including CI, because our cache had expired that morning.

The library did one thing, had 1.2 million weekly downloads, and one person with publish rights. Every property that made it a reasonable dependency — small, focused, popular — was independent of the property that broke us.

Bus factor applies to your supply chain. For a small, stable dependency, vendoring the source is usually cheaper than the recovery, and the decision is a two-minute judgement at adoption time.

The case this post is about

An ordinary transitive dependency:

our-app
 └── form-validation:3.1.0
      └── phone-format:2.4.1        ← 340 lines, 1 maintainer, 1.2M weekly downloads

Nobody chose phone-format. It arrived with form-validation, which was a deliberate choice by someone three years earlier.

The symptom

09:12  CI: could not resolve phone-format:2.4.1 — 404
09:14  11 repositories failing the same way
09:15  local builds: fine (cached in ~/.gradle)
09:31  a developer runs a clean build: broken
10:04  the registry page returns 404; the GitHub repository is gone

And the recovery was slower than it should have been because of three compounding facts:

- no lockfile pinning a hash   → we could not verify a replacement was identical
- no internal artifact mirror  → nothing to fall back to
- the CI cache had rotated     → the one copy that would have unblocked us was gone

What we tried, in order, and how long each took:

10:10  ask the maintainer                     no response (account deleted)
10:25  find a fork                            6 forks, none published, none identical
10:40  extract from a developer's ~/.gradle   found it — ONE machine had 2.4.1 cached
11:05  publish to our internal repository     unblocked CI
14:30  vendor the source properly

Four and a half hours, and the thing that saved us was one laptop's cache. That is not a recovery plan.

Then the audit, which was the real finding:

direct + transitive dependencies:        412
with a single publisher identity:         89   (21.6%)
  of those, under 1,000 lines:            34
  of those, no release in 18 months:      17
  of those, we use ≤2 functions from:     22

Twenty-two dependencies where we used two functions or fewer from a single-maintainer package.

Why the obvious fix failed

"Pin the exact version." We did — 2.4.1, pinned. Pinning protects against a bad new version; it does nothing when the artifact is removed. The pin is what made the 404 immediate rather than silently resolving to something else.

"Fork it on GitHub." Six people had. A fork preserves the source and does not publish an artifact, so the build still cannot resolve it. And none of the forks matched 2.4.1's published bytes, which we could not verify anyway without a hash.

"Use a lockfile." Necessary and insufficient on its own: a lockfile with hashes would have let us verify a replacement, and it still needs an artifact to exist somewhere.

"Vendor everything." Overcorrection. Vendoring a 2MB HTTP client with an active security response team is worse than depending on it — you inherit the patching burden and will fall behind. Vendoring is right for the small-and-stable, wrong for the large-and-maintained.

The actual mechanism

A dependency's risk has two dimensions that people conflate:

Technical quality — is it well-written, tested, documented, popular? phone-format scored well on all four.

Supply-chain continuity — can it stop being available? That is a function of how many people hold publish rights, whether the registry permits deletion, and whether anything else keeps a copy.

Those are independent. A high-quality package with one maintainer and a deletable registry entry is a single point of failure regardless of its code.

And three structural facts made our exposure worse than it looked:

Transitive dependencies are not chosen. We never evaluated phone-format. Nobody did — it came with something else, three levels of judgement away from any decision we made.

A cache is not an archive. ~/.gradle and the CI cache made us feel insulated right up to the moment the cache rotated. A cache's job is speed, and it has an eviction policy.

And "popular" provides no continuity guarantee. 1.2 million weekly downloads means many people share your exposure, not that the artifact persists.

Dependency risk split into technical quality and supply-chain continuity as independent dimensions, with a well-tested popular package still having one publisher identity and a deletable registry entry — and three compounding facts: transitive dependencies are never evaluated, a cache has an eviction policy rather than being an archive, and popularity provides no persistence guarantee

Continuity, unlike quality, is mostly computable — the registry publishes who can publish. So the audit nobody had run is a loop over the resolved tree asking one question per package:

RISK = {"critical": 0, "elevated": 0, "ok": 0}

def continuity(pkg, meta, direct_deps):
    """meta: registry metadata. Returns (level, reason) — never a bare score."""
    maintainers = len(meta["maintainers"])
    age_days    = meta["days_since_last_publish"]

    # Popularity is deliberately NOT an input. 1.2M weekly downloads means many
    # people share the exposure; it does not make the artifact persist.
    if maintainers == 1 and meta["registry_allows_unpublish"]:
        return "critical", f"1 maintainer, unpublishable from {meta['registry']}"
    if maintainers == 1:
        return "elevated", "1 maintainer"
    if age_days > 730 and pkg not in direct_deps:
        # Unmaintained AND transitive: nobody here chose it and nobody upstream is watching.
        return "elevated", f"no publish in {age_days}d, reached transitively"
    return "ok", ""

def audit(tree, direct_deps, fetch_meta):
    findings = []
    for pkg in tree:                       # the RESOLVED tree, not the manifest
        level, reason = continuity(pkg, fetch_meta(pkg), direct_deps)
        RISK[level] += 1
        if level != "ok":
            findings.append((level, pkg, reason))
    # Sorted so `critical` reads first — a 300-line report filed by name gets skimmed.
    return sorted(findings), RISK

Run across the resolved graph as it stood that morning:

critical  [email protected]        1 maintainer, unpublishable from npm
critical  [email protected]           1 maintainer, unpublishable from npm
elevated  [email protected]         no publish in 1,340d, reached transitively
elevated  [email protected]           1 maintainer
…
critical 2 · elevated 19 · ok 847

Two critical, out of 868 packages, neither of them chosen by anyone on the team. That ratio is the argument for running this as a report rather than as a conversation — nobody was going to find those two by reading a manifest they do not appear in.

Bus factor is a supply-chain property. "Well-written, popular, focused" are quality signals and say nothing about whether the artifact will exist next week.

The fix

Three layers: a mirror so nothing can disappear, lockfiles so replacements are verifiable, and vendoring for the small-and-stable.

1. An internal mirror, which is the layer that makes this a non-event:

// settings.gradle.kts — the internal proxy is FIRST and caches everything it serves
dependencyResolutionManagement {
    repositories {
        maven {
            url = uri("https://artifacts.internal.example/maven-public")   // proxies + caches
            content { includeGroupAndSubgroups("com") }
        }
        mavenCentral()        // fallback for a cold proxy
    }
}

A caching proxy (Artifactory, Nexus, or a self-hosted alternative) keeps a copy of every artifact it has ever served. A deleted upstream becomes invisible to your builds.

2. Lockfiles with hashes, so a replacement is verifiable:

// build.gradle.kts
dependencyLocking { lockAllConfigurations() }
$ ./gradlew dependencies --write-locks
# commits gradle/dependency-locks/*.lockfile with module+version
# plus verification metadata for hashes:
$ ./gradlew --write-verification-metadata sha256 help
<!-- gradle/verification-metadata.xml -->
<component group="io.example" name="phone-format" version="2.4.1">
  <artifact name="phone-format-2.4.1.jar">
    <sha256 value="9f2c…"/>
  </artifact>
</component>

With that hash recorded, a fork's build can be verified byte-identical — the check we could not perform during the incident.

3. Vendor the small-and-stable, with a documented rule:

## Vendoring rule
Vendor a dependency when ALL of:
  - fewer than ~1,000 lines of source we actually use
  - a single publisher identity (check the registry's maintainer list)
  - no security-sensitive surface (no crypto, no parsing of untrusted input, no network)
  - we use a small, stable part of its API

Do NOT vendor: anything with an active security response need, anything large,
anything we would not be willing to patch ourselves.
// vendored/phone-format/  — 340 lines, LICENSE and PROVENANCE.md included
PROVENANCE.md:
  source:  io.example:phone-format:2.4.1
  sha256:  9f2c…
  license: MIT (copy in LICENSE)
  reason:  single maintainer, 340 lines, we use 2 of 11 functions
  vendored: 2026-11-15 by @rajan, ADR-041

Licence compliance is part of this and is easy to skip: vendoring is redistribution, so the licence and attribution come with it.

4. And a continuity check at adoption time, which is the cheap preventative:

# two minutes, when a dependency is added
$ ./scripts/dep-continuity.sh io.example:phone-format
  publishers:            1
  last release:          18 months ago
  source available:      yes (MIT)
  size (our usage):      340 lines, 2/11 functions used
  → RECOMMENDATION: vendor (rule: single-publisher + small + non-security)

How we proved it

That the mirror makes a deletion invisible, tested by deleting an artifact from the upstream view:

simulate: block registry.example in the firewall, clear local caches
  before (direct to upstream):  FAILED — could not resolve 4 dependencies
  after (internal proxy):        BUILD SUCCESSFUL in 41s

That verification metadata catches a substituted artifact — the check that would have let us trust a fork:

# publish a fork's build under the same coordinates and resolve it
$ ./gradlew build
  FAILED: Dependency verification failed for io.example:phone-format:2.4.1
    Expected sha256 9f2c… but was 3a71…

That failure is the useful outcome: it tells you the fork is not the same bytes, which is information, rather than silently building something different.

The audit, acted on:

candidates for vendoring (by the rule):        22
vendored:                                       9
  total vendored lines:                     3,140
  average per dependency:                     349
left as dependencies (rule excluded them):     13
  reasons: 6 security-sensitive, 4 too large, 3 actively maintained multi-publisher

Nine vendored, thirteen deliberately not. Reporting both is the point — the rule's value is that it says no as often as it says yes.

The ongoing cost of vendoring, measured over six months:

upstream changes to the 9 vendored libraries:   4 releases total
  of which we needed:                            1 (a bug fix we applied by hand, 20 min)
  of which were security:                        0
maintenance burden:                             ~20 minutes in 6 months

Twenty minutes in six months against four and a half hours of incident. That is the trade, and it only holds for the small-and-stable category the rule describes.

One limit I want to be clear about: the mirror is now a single point of failure of its own. If the internal proxy is down, every build is blocked — so it needs the availability treatment any production dependency gets, and ours is replicated with a documented restore procedure. Replacing an external SPOF with an internal one is only progress if you then operate it properly.

What this generalizes to

Risk has dimensions that do not correlate. Code quality, popularity, and continuity are independent. Evaluating a dependency on the first two and inferring the third is the error, and "1.2 million downloads" is reassuring about exactly the wrong thing.

A cache is not an archive. Caches have eviction policies and exist for speed. If your only copy of something is in a cache, you do not have a copy. This applies to artifact caches, Docker layer caches, CDN caches and browser storage alike.

You did not choose your transitive dependencies. Most of the supply chain arrived with something else. Periodically enumerating what you actually depend on — and on how many people — is the only way the list becomes a decision.

Vendor the small-and-stable; depend on the large-and-maintained. Vendoring is not a universal answer. The rule's exclusions are as important as its inclusions, and a written rule is what stops the next incident producing an overcorrection.

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".

Follow by RSS