Rails World '26: The As-If Rule, Ractors & AI

Rails World '26: The As-If Rule, Ractors & AI

I was in the audience at Rails World 2026 when Aaron Patterson (tenderlove) kicked off the closing keynote. Aaron opened with a well-worn line: “the purpose of a system is what it does.” He wasn’t satisfied with it. The purpose of a system, he argued, is what it does for whoever is observing it.

Hand an old Nokia 3310 to someone who has never seen a cell phone before, and they might use it as a hammer. In that moment, to that person, it is a hammer. What a system does depends on who’s watching.

That distinction has a name in compiler design: The as-if rule.

In this post, we’ll walk through what the as-if rule means for Ruby’s compiler, where Ractors push it right up to its edges, and how the same idea explains a real RubyGems.org security incident that played out earlier this year.

The Rails World 2026 main stage before the closing keynote, with screens reading 'Up next on this track: Rails World 2026 Closing Keynote, Aaron Patterson, Rails Core' above a packed audience

The as-if rule, in plain terms

The as-if rule comes from the C standard, and Aaron read the actual text of it from the stage:

A conforming implementation is allowed to apply any optimizing transformation to a program during compiling, provided that such optimization makes no change in the observable behavior of the program, as specified by the standard.

Translated: The compiler can do whatever it wants to your code, reorder it, inline it, delete parts of it entirely, as long as the result behaves as if it had run exactly the way you wrote it.

Ruby doesn’t have a standard the way C does, but the same contract holds. The question Aaron kept coming back to is: who gets to decide what counts as “observable”? His answer was that it depends entirely on who’s watching. Change the observer, and you change what the optimizer is allowed to get away with.

Ractors are a great example of exactly that.

Ractors add a new observer to your code

Aaron’s first example is a pattern almost every Rails developer has written:

class Foo
  def self.expensive_thing
    @expensive_thing ||= compute_expensive_thing
  end
end

Compute it once, memoize it in a class-level instance variable, done. Under the GVL, this “works,” even though there’s technically a race condition in it.

Two threads could both see @expensive_thing as nil and both run compute_expensive_thing. It’s wasteful, but it’s not dangerous, because the GVL guarantees only one thread is ever really executing Ruby code at a time. There has only ever been one observer of that code path at once.

Ractors change that.

They give Ruby true parallel execution, which means two Ractors really can hit that same line of code at the same moment. The as-if rule hasn’t changed, but the set of observers has, and the old memoization pattern was only ever safe for one kind of observer.

The good news, Aaron pointed out, is that Ractor doesn’t let this fail silently. Try to write to a class-level instance variable from a non-main Ractor and it raises an exception.

You get an exception telling you exactly what went wrong instead of a quietly corrupted cache. He showed two versions of the same pattern side by side: one where multiple Ractors hit the same memoized method on a class (raises), and one where the memoization is scoped so only a single Ractor ever touches it (completely fine, same code).

The pattern isn’t the problem. Having more than one observer of it is.

Because this class-level memoization pattern is so common in Rails applications, an upcoming Rails release ships an ActiveSupport::Ractors module with an on_main method, which lets you force a specific block of code to run on the main Ractor.

If your memoization needs to happen once and get shared, on_main gives you a safe place to do it. The catch, and Aaron was upfront about this, is that shoving too much work onto the main Ractor turns it into a bottleneck.

Every other Ractor ends up contending for it, and you trade a race condition for added latency.

For anything beyond occasional memoization, Aaron pointed people toward the ractor-sharing gem, written by Koichi Sasada, Ractor’s original author.

It ships with thread-safe data structures built for this exact situation, and he called out two: lock variables, which guarantee a computation runs exactly once (for anything non-idempotent), and TVars, which allow a computation to run more than once safely (fine when the computation is idempotent and you’d rather have throughput than a strict single execution).

Which one you reach for depends on whether “ran twice” is a bug or just a little wasted work.

Aaron’s broader point was not to be scared off Ractors.

In a typical request-response web server, almost all of your object allocation happens inside the method that serves a single request, so it’s naturally isolated per Ractor.

The places you actually have to think about thread safety are the much smaller set of things a request touches from outside that scope: constants, configuration, shared caches. The blast radius is smaller than it feels.

Ractor-local GC, and why it’s not quite that simple

Ruby 4.1 ships a Ractor-local garbage collector.

Today, Ruby’s GC is a singleton.

Every Ractor asking for a new object has to go through the same collector, and with multiple Ractors running in parallel, that collector becomes a bottleneck all its own. A Ractor-local GC gives each Ractor its own heap, so allocation no longer means waiting in line behind every other Ractor.

That opens up a tempting idea: spin up a new Ractor for every incoming request, let it allocate into its own heap, then throw the whole heap away once the request finishes, cheaper garbage collection, basically for free.

Aaron then showed why it isn’t quite that clean.

If a producer Ractor allocates an object and hands it to a consumer Ractor, both Ractors can now point at the exact same object (he confirmed it live with matching object_ids).

Once that happens, killing off the producer Ractor and discarding its heap isn’t safe.

Something else might still be pointing into it.

Ruby needs a way to reconcile that, and it does it with a global GC pass, similar in spirit to a major GC, that runs less often than a full collection but still has to happen.

He pushed the example one step further with something that happens implicitly, without anyone passing an object on purpose. Parse the same JSON key (say, "hello") in two different Ractors, and both Ractors end up with the exact same object for that string, deduplicated automatically.

Split that same JSON-parsing code across two Ractors and you still see identical object IDs for the key. Nobody wrote code to share that string. It’s an implicit consequence of how string interning works, and it means Ractors can be quietly affecting your GC behavior in ways you can’t see just from reading the code.

When RubyGems.org became the observer

The second half of the talk moved away from language internals and into a real security incident from earlier this year, one Aaron was personally pulled into because of his work on the RubyGems client.

It started on May 11th and 12th, when RubyGems.org began receiving thousands of junk gem uploads opens a new window . There wasn’t a name for it yet, someone on the RubyGems.org team noticed the flood, disclosed it publicly, and paused new account sign-ups while the team worked through it.

A slide titled 'GemStuffer Code' showing a malicious Ruby script reaching out to a UK government server and back, with RubyGems.org above it

Socket.dev later wrote it up and gave it a name: the GemStuffer campaign opens a new window . Their post described gems containing a script that reached out to a remote server, pulled down data, repackaged that data into a new gem, and uploaded it right back to RubyGems.org.

When Aaron first read that writeup, his reaction was skepticism. The malicious code lived in a plain Ruby file, not inside a C extension’s extconf.rb, which is the usual way a gem gets code execution just from being installed. Without that, he couldn’t see how the script would ever actually run.

Sign-ups reopened on May 16th, and he mostly moved on.

A quiet cache poisoning bug

On July 6th, a security researcher at Truffle Security reported a cache poisoning issue in RubyGems.org opens a new window , found completely independently of anything OpenAI-related.

The RubyGems client authorizes via a GET request with HTTP Basic auth, and the server responds with an API key. Fastly sits in front of RubyGems.org and, under the right conditions, was caching that authenticated GET response, key included.

Send an unauthenticated request with the right Accept-Encoding: gzip header (which curl doesn’t set by default, but Ruby’s Net::HTTP does), and you could get back someone else’s cached, valid API key.

There was no evidence of active abuse at the time it was found, the exploitable window was short-lived, and the RubyGems.org team shipped a fix within three days.

It’s easy to see why nobody had flagged it sooner: a maintainer whose key got clobbered would just see a confusing “not authorized” error on gem push, run gem login again, and never think twice about it.

The chain nobody expected

On September 8th, Aaron got pulled into a conversation, through a mutual contact, with a researcher who told him something that stopped him cold: bots associated with OpenAI had attempted to exploit that exact cache poisoning bug to steal RubyGems users’ API keys, and the trail led back to the original GemStuffer gems from May.

Here’s the chain, as Aaron reconstructed it from what he was shown.

A gem published to RubyGems.org can include a .yardopts file. If the popular yard documentation gem is installed (and it registers itself as a RubyGems plugin), installing any gem with a .yardopts pointing at a script will cause that script to execute.

Separately, RubyDoc.info, a third-party documentation site with no operational relationship to RubyGems.org, subscribes to a webhook that fires on every new gem publish.

When it fires, RubyDoc.info downloads the new gem, runs it inside a Docker container to generate docs via yard, and that container had outbound network access.

Put those three unrelated facts together, a gem with a malicious .yardopts, yard’s auto-execution behavior, and a documentation host that runs untrusted code with a live network connection, and you get remote code execution triggered automatically by the act of publishing a gem.

The script that ran inside that container made requests against RubyGems.org’s authorization endpoint, using the same cache poisoning bug reported in July, looking for a response matching the shape of a valid API key.

A slide titled 'Excerpt from "slnleaker5" Gem' showing script.rb making a Net::HTTP request to rubygems.org with SSL verification disabled, then matching the response body against the rubygems_ API key pattern

Any key it captured, or other harvested data, got packaged into a new gem and uploaded back to RubyGems.org, which fired the webhook again, which ran the script again, closing the loop.

Aaron’s read on the timeline is that the May GemStuffer gems were the earliest version of this same chain, well before anyone had connected it to a cache poisoning bug, and well before the July report.

His theory for why some of the May gems don’t contain the key-stealing code and others do: once the RubyGems.org team started noticing and shutting down the accounts behind the junk uploads, whoever was running these bots needed a way back in without credentials, and that’s what pushed them toward burning a zero-day to harvest working API keys instead.

RubyGems.org fixed the underlying cache issue back in July, and as far as anyone has found, no user was actually exploited through this chain. The full writeup opens a new window from the Truffle Security researcher who connected the dots went public on September 14th.

What this has to do with AI

Aaron Patterson on stage at Rails World 2026, in front of the glowing 'Rails World' logo

Aaron closed by connecting both threads back to where he started. He called himself an optimist, not necessarily an AI optimist, just an optimist, and said he uses AI to write code every day.

But he pushed back on the comparison people love to make, that AI is “just the next compiler,” so you don’t need to read its output any more than you read assembly.

The reason you don’t read your compiler’s output is that the compiler made you a promise: the as-if rule.

Whatever transformation it applies, the behavior you can observe matches the code you wrote. Everything in his talk, Ractors raising instead of corrupting, the GemStuffer chain getting found and fixed, was really about what it costs to keep that promise.

Your AI hasn’t made you that promise. There’s no as-if rule for a model’s output. So, as he put it, he’s not going to concede his destiny to AI.

That’s not a gloomy take, though. Aaron said he thinks this is the best time to be alive, and that it’s only going to get better.

Then he wrapped up the talk with this slide:

The 'Get in loser' scene from Mean Girls, with the second caption changed to 'We're going PROGRAMMING'

Get in, we’re going programming

I walked out of that closing keynote excited about the future of Rails, and I could tell I wasn’t the only one in the room.

Aaron’s talk touched on things that are often invisible to people who use Rails every day: performance, security, and the joy of programming at a time when so much code is AI-generated.

You don’t need to think about the as-if rule to ship a feature. But someone does, and it’s great to see how much the people working on Ruby and Rails care about keeping that promise.

I’m with Aaron on this one. I use AI every day, and I still want to know what the code is doing.

I still want humans in the loop to confirm that the right assumptions were made, that we understand our users’ comments, feedback, and needs, and that we keep an eye on the high-level decisions that make a project succeed or struggle.

AI can write a lot of the code. Understanding it is still our job, and that’s the fun part.

Are you weighing how Ractors, ZJIT, or a newer Ruby fit into your Rails app’s roadmap? Talk to us today! opens a new window

Get the book