Open with the failure mode as a question to the reader: what happens when an OSS patch release renames a method Pro overrides?
Bundler resolves it. The constant still loads. The specs you remembered to write still pass. Pro just quietly stops doing its job.
Concrete stakes: Pro prepends a module onto Ductwork::Models::Branch, OSS deletes #route_by_edge in a refactor, and Pro's override is now dead code prepended onto nothing.
One line on what this post is: the tool I built to catch that, which I call drift.
How Pro extends OSS (and why that's fragile)
Brief open-core context: Ductwork is LGPL, Pro is a separate gem that depends on ductwork ~> 1.4.0.
Pro's three moves: prepend a module onto an OSS class, subclass an OSS class, call OSS methods directly.
Why prepend instead of a plugin/hook API. TODO(Tyler): your actual reasoning (speed of shipping Pro features? not wanting to freeze a public extension API?).
The core problem: Pro's coupling to OSS internals is invisible to the dependency resolver. Semver protects public API, and Pro isn't touching public API.
Coin a term for this? Something like "silent drift" to match Double Work / Lost Work from the Atomicity Gap post.
What I wanted out of a solution
Tell me before I ship that something Pro touches moved, and only that. Most OSS changes have nothing to do with Pro.
Rank the results by how badly Pro is likely to break.
TODO(Tyler): alternatives you considered and dropped (run Pro's suite against OSS main? pin exact OSS versions? just be careful?). Why each wasn't enough. Running specs catches that something broke, not where, and only for paths you tested.
Step 1: Fingerprint OSS
Prism parses every Ruby file in the OSS gemspec.
Everything gets filed into four buckets (namespaces, methods, class << self bodies, top-level defs), keyed by Ruby's own naming: Ductwork::Models::Branch#claim, Ductwork::Models::Branch.claim.
Normalize before hashing, otherwise the signal drowns in noise:
lex with Prism.lex, drop comments, turn top-level newlines into ;, drop newlines inside brackets
SHA-256 the token stream
result: reformatting doesn't drift, renaming a local variable does
Classes are hashed as a "shell" with their methods cut out, so a method body change moves the method's hash, not the class's.
Show a small before/after snippet: two formattings of the same method that produce the same digest.
The baseline lives in a committed .ductwork/state.json with the OSS SHA and version it was taken from.
Size of it: ~99 namespaces, ~373 methods. TODO: re-check numbers before publishing.
Step 2: Map what Pro touches
Same Prism walk, but over Pro's own files, looking for every place Pro reaches into OSS.
The four coupling types, strongest first (table):
replacement: Pro overrides in a prepended module, never calls super
extension: Pro overrides and calls super, so it depends on the OSS body still running
subclass
reference: Pro just names or calls it
Point out the interesting one: super is what separates replacement from extension, and it changes what a diff means.
Size of it: ~170 touchpoints, 86 of them full replacements. TODO: re-check numbers, and decide how much of Pro's internals you want public (see note at the end).
Step 3: Triage, the filter that makes it worth reading
Diff the baseline against fresh fingerprints: additions, deletions, changes.
Something is a candidate only if:
Pro touches it directly (any delta counts), or
it's a new member of a class Pro replaced, extended, or subclassed (possible collision)
New methods on a class Pro only calls are ignored on purpose. Calling into a class doesn't make its new methods Pro's problem.
Sort by coupling strength so the scariest rows are first.
Step 4: The report
Show the sample table (deletion/replacement, change/extension, addition/subclass) and walk through what each row means in plain English.
DRIFT_DIFF=candidates attaches the actual git diff hunks for each flagged file.
Mention handing the whole report to an LLM: it has the "what moved" and the "how" in one blob.
TODO(Tyler): a real catch. The README's example is the Time.current to Ductwork::DatabaseClock.now change in branch.rb. Was that real? Did drift flag it? A true story here beats the synthetic table.
Wiring it across two repos
OSS side is tiny: on every push to main, fire a workflow_dispatch at Pro with the pushed SHA.
Pro side: check out OSS at that SHA, run drift:triage, dump the report into the job summary. Also runs nightly.
Exit codes: 0 clean, 1 candidates, 2 error. A red run in Pro is the alert.
The baseline stays put until you re-run drift:digest and commit it, so CI stays red until someone actually deals with it. Opinion: that's a feature.
Small gotcha worth a sentence: actions/checkout is shallow by default, so the diff mode can't find the baseline commit in CI.
The upgrade loop
Short numbered list: point the OSS checkout at the target commit, triage, work the list top to bottom, fix Pro, re-digest, commit the state file with the fix.
Why committing the state file matters: a stale baseline means re-triaging stuff you already fixed.
Being honest about the gaps
Static analysis only: const_get, string-built constants, method_missing are invisible.
Ruby only: ERB views and migration templates aren't fingerprinted.
No transitive reasoning: OSS rewrites a private helper your override calls, the override's digest doesn't move.
Inherited changes on classes Pro subclasses aren't flagged, only additions.
Some noise on purpose: reordering keyword args drifts.
Frame: it's a tripwire, not a proof. A clean run means "nothing obvious," not "safe."
Wrap-up
Who else this is for: anyone shipping a paid gem on top of an open source one (Sidekiq Pro, Oban Pro, etc.), or a big app full of prepends onto a gem.
TODO(Tyler): is drift going to be extracted into its own gem? If yes, that's the call to action. If not, link to Pro / Ductwork.