Aquileo | Sam RubyIt’s just data2026-08-03T21:21:52.000Zhttps://intertwingly.net/blog/Sam Rubyrubys@intertwingly.netAquileo | Standing Behind the Numbers2026-08-03T21:21:52.000Ztag:intertwingly.net,2004:3454

Six days ago the ledger read: ten of twenty-six routes, thirty-nine of 354 tests, and a benchmark page that refused to stand behind its own timings. A progress update: the compiled Lobsters now renders what Rails renders on twenty-four of twenty-six routes at 5.2× Rails' speed in a fifth of the memory, the conformance lane tripled, the page stands behind every number on it — and the compiler is growing a generational garbage collector with this application as the forcing function.

Six days ago I published a ledger that didn't flatter: the compiled Lobsters served ten of twenty-six routes, thirty-nine of 354 of its own tests passed, and the benchmark page opened with a banner declining to stand behind its own timings. The deal I made was to keep publishing the ledger either way.

This is the update, under the same rules.

The numbers, as of this afternoon

The compiled binary now serves twenty-four of twenty-six routes rendering what Rails renders — compared as parsed documents against Rails' own output, not as status codes. The two exceptions are named on the page with the reason and the work that closes each, and their visits are subtracted from every lane's timing, Rails included, so all five lanes still measure identical work.

On that identical work, under the ruby-bench harness's own timing rules — Shopify's benchmark, their warmup discipline, their statistic — the compiled binary runs the frozen sequence at 5.2× the speed of Rails on YJIT, in about a fifth of the memory (72 MB peak against 346 MB). The plain-Ruby emit — Rails semantics compiled to ordinary Ruby, no AOT involved — sits at 3.8×.

That 3.8× is worth a sentence, because it used to be 4.0×, and it went down when the comparison got fairer. Making a benchmark honest moves numbers in both directions, and a page that only ever moves in its own favor is telling you something.

What "trustworthy" took

The banner six days ago was automatic: the page red-flags itself whenever its own data shows the lanes didn't do the same work. Earning its removal took three rounds, each of which found something quietly wrong.

First, a one-line bug that let the Rails lanes time eight visits the other lanes had deferred — three published runs' worth of ratios divided different work before the page caught it. Second, the timing loop matched ruby-bench's rule approximately rather than exactly — warmup counts and the reported statistic have now been made verbatim. Third, and my favorite: the five lanes turned out to be quietly running three different builds of SQLite. Nothing recorded it, so a library-version difference could wear a runtime difference's clothes. Every lane now asks its own linked library what it is at runtime, the page attests the match — and the largest "compiler loss" in the per-route table, a query-heavy route the profiler showed spending 94% of its time inside SQLite itself, turned out to be an eighteen-month SQLite version gap. Aligned, it's a win.

The pattern across all three: every explanation anyone guessed — including mine, including ones I'd published — died when measured. The instruments are what's left standing, and they're on the page: which commit, which compiler, which library, which work.

The forcing function, promoted

The last post said a forcing function does its job and then stops being interesting — that's why the blog demo chapter closed. Lobsters' job turns out to be bigger: it is currently forcing a generational garbage collector into the compiler.

The short version. With everything aligned, the seven routes where the compiled binary still loses to YJIT — all of them cheap, sub-millisecond requests — trace to a single cause: Spinel's collector re-walks the entire live heap on every collection, so a request that does almost no work of its own still pays rent on the whole application's memory. We published the analysis with the workload attached; Matz confirmed the mechanism with his own measurements, landed the write barrier a generational collector needs (cost on this workload: about 1.4%), and landed the minor mark itself behind an opt-in flag — citing the issue thread at each step. I ran it the same day; his verification tooling caught a remaining coverage gap on this app in seconds, and the report was filed while Tokyo slept. The prediction, on the record: when that work completes, the compiled binary wins every route on the table.

That loop — publish the workload, measure together, fix on whichever side of the fence the cause lives — has run four times in six days. It is the most productive collaboration mode I know, and all of it happens in public issues.

The other lane

The conformance lane — upstream Lobsters at HEAD, running its own test suite against the transpiled output, the lane that tests whether you have to change your application — went from thirty-nine passing examples to 126 of 354. Still a failing grade, still published, still the finer oracle: each failure names a specific construct, and that page's own rules bar the shortcuts (a pass rate achieved by propping up the subject is not a pass rate about the subject).

One item from the last post deserves its payoff. I wrote then that the compiled lane's Rails.cache was a no-op — every fetch recomputed, "a different program from the one Rails is running." That's fixed: the compiled binary now runs the real cache, and serves Lobsters' heaviest cached page from a hit in a quarter of a millisecond.

Still true

It's still early, and the page still moves underneath this post — there is an intermittent crash in the compiled lane under investigation right now, and on any given afternoon you may click through and find a lane honestly reporting itself dead. That's the design. The question from six days ago hasn't changed: whether the application you already have can come with you. What changed is the evidence.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | What Happens After You IPO?2026-07-28T23:47:33.000Ztag:intertwingly.net,2004:3453

Rails now leads with "convention over configuration" as a pitch to coding agents — token-efficient code that machines write well. That is the same property a compiler needs, and Rails has spent twenty years building it. The homepage promises a path from prompt to IPO; this post is about the part after. The Spinel-backed Rails blog that matz's issue tracker asked for exists and has been on a RubyConf stage. The next target is a real application — Lobsters — measured in two lanes that keep each other honest: speed against a frozen benchmark snapshot, fidelity against upstream HEAD running Lobsters' own test suite. Thirty-nine of 354 examples pass today. Here's the ledger, including the numbers my own benchmark page refuses to stand behind.

Go look at rubyonrails.org right now. The first thing you see:

Accelerate your agents with convention over configuration.

Ruby on Rails scales from PROMPT to IPO. Token-efficient code that's easy for agents to write and beautiful for humans to review.

I want to sit with that for a second, because I think it's more interesting than it first appears.

The property Rails is selling

The argument on that homepage is that conventions make Rails good for machines. An agent doesn't have to discover where things live, or infer a naming scheme, or read your configuration to find out what you decided this week. A model named Story has a table named stories, a controller named StoriesController, and views under app/views/stories. You know that without looking. So does the agent. That's what "token efficient" means — the conventions carry information that would otherwise have to be spelled out.

Here's the thing: that is exactly the property a compiler needs.

Static analysis is easy when structure is predictable and hard when it isn't. Every convention Rails imposes is a fact an analyzer gets for free, and every configuration escape hatch is a fact it has to prove. Rails spent twenty years building what amounts to an ideal input language for a transpiler, and has now put that on the homepage as a feature — for a different reason, aimed at a different consumer.

An agent and a compiler want the same thing. The homepage's other half stays true either way: still beautiful for humans to review, because nobody had to contort the source to make it machine-legible. The conventions were already there.

The part after the arrow

"Scales from PROMPT to IPO" is a good line, and Rails has genuinely earned the first half of it. Nothing gets you from an idea to a running application faster.

So: what happens after you IPO?

The historical answer is that you leave. You hit a wall — throughput, latency, infrastructure cost, headcount spent on servers — and the remedy is a rewrite in something that compiles. That story has played out enough times to be a genre. The rewrite is expensive, it takes years, it loses the conventions that made you fast in the first place, and the new thing is worse to work in.

I don't think that trade is necessary, and the goal of this project is narrower and more specific than "make Rails fast":

You shouldn't have to change your application.

Not port it. Not annotate it. Not restructure it to suit a tool, not adopt a subset, not maintain a parallel version. The Rails you already wrote — the one your team knows, the one your agents are good at writing — should be the input. If a compiler needs something the app doesn't provide, that's the compiler's problem to solve, not yours.

That constraint is what makes this hard, and it's also the only version of the idea worth pursuing. A transpiler that requires you to rewrite your app has just reinvented the rewrite.

The blog, and a closed chapter

Three months ago, in an issue on matz/spinel that was mostly about four type-inference gaps, I floated a hope: that a Rails blog demo running on top of Spinel — Matz's ahead-of-time Ruby compiler — would be a valuable addition to the demos already in place. The answer, in issue #83:

the Rails-blog-as-input demo is a great forcing function and a different shape of input from the small repros that drive most of the issue tracker today, so yes, a Spinel-backed Rails blog would be welcome alongside the existing demos.

The same reply set the working method, and it's the one still in use: file each gap as its own minimal reproduction, title it by symptom rather than by mechanism, one concrete failure per issue. Small, contained reproducers are easy to triage; the fix usually lands in a commit or two.

Three months later the blog transpiles, compiles to a native binary, and runs. It was on the Main Stage at RubyConf twelve days ago, with Matz in the room, and the talk and its demos are online.

A forcing function does its job and then stops being interesting. A generated blog is a scaffold — it has the shape Rails intends, not the shape applications acquire. Closing that chapter means picking a harder input.

Two Lobsters, measured on purpose

Lobsters is a real, community-run Rails application: the link-aggregation site, not a scaffold. It carries what real apps carry — hand-rolled scopes, concerns, callbacks, view helpers, query patterns no generator produces. It's also a known quantity, because the Ruby and Rails Infrastructure team at Shopify turned it into a benchmark for YJIT for exactly this reason: it stresses a real Rails request the way a microbenchmark can't.

Getting Lobsters onto Spinel is a big job. Rather than treat it as one push, it's split into two smaller ones that measure different things — and, more usefully, that catch each other lying.

The benchmark lane runs the capture frozen into ruby/ruby-bench, pinned to a fixed commit. A benchmark has to freeze its input or the numbers stop comparing between runs. The trade is that it describes the app as it was when the snapshot was taken.

The conformance lane answers the other question: does this handle the code they have now? It tracks upstream HEAD, records the commit it ran against instead of pinning it, and runs Lobsters' own RSpec suite against a transpile of that same checkout.

That second lane is the one that tests the actual thesis. Their tests, their app, unmodified — if the output passes the suite the original passes, then "you don't have to change your application" is a measurement rather than a slogan. And a test suite is a far finer oracle than a route returning 200: each failure names a specific construct that isn't modeled yet, which turns the suite into a worklist generator.

Speed without fidelity is a benchmark for the wrong program. Fidelity without speed is a slower Rails. You need both lanes, and you need them reported separately, because the standard way this kind of post lies is to prove one and imply the other.

Where it actually stands

Both lanes ran today, against Lobsters 4227239d and Roundhouse f0b3ab24. Numbers below are from those runs.

Conformance. 354 examples across 25 of 104 spec files — model specs, the ones that exercise the compiler surface most densely; request, feature, mailbox and routing specs aren't run yet. 39 pass. 315 fail.

That is not a good score, and I'd rather publish it than round it. What makes it worth publishing is the shape: two causes account for 211 of the 315. Keystore.upsert isn't implemented (133 examples), and the SearchParser class emits empty because a parslet DSL is dropped on the floor (78). The rest of the ranked list runs 24, 20, 16, 11, 5, 5 — a Symbol used where an integer index was expected, sixteen cases of a saved_change_to_<column>? method that isn't synthesized, nil reaching integer arithmetic. Every one of them names something specific. None of them says "Rails is too dynamic."

The transpile itself produces 743 files with zero errors and 2,361 warnings. The warnings are the honest part: modeling debt, itemized, on the exact line.

Benchmark. The Ruby emit — Rails semantics compiled to plain Ruby, still running on CRuby — serves 26 of 26 routes with a 200, and 21 of those render what Rails renders. At 133.3 ms/iteration against Rails' 491.3 under YJIT, that's 3.69× faster; without JIT, 167.2 against 821.0, or 4.91×. Those numbers are solid and they are not the Spinel story — that's what removing framework overhead buys you before any compiler gets involved.

The Spinel lane, which is the actual target, serves 10 of 26 routes. Sixteen return 500. And the benchmark page says this about its own timings:

The performance numbers on this page are not trustworthy.

It says that because the AOT lane failed 72 visits during the timing run, which makes the measurement optimistic in a way that isn't recoverable by squinting. A lane that dies on two-thirds of its routes and still posts a time is posting the time of the routes that didn't die.

If you click through and find different numbers — or a lane that isn't running at all — that's why the commit is published next to them. Both lanes sit on upstream that moves underneath them, and that page is the live record. This is a snapshot of one afternoon.

One asymmetry bears on every number above. Lobsters caches its heaviest work through Rails.cache — the user tree for a day, the front page for 45 seconds, individual stories for a minute. The CRuby lane has a real in-memory store behind that, close enough to ActiveSupport's MemoryStore to behave like one. The Spinel lane has a no-op: every fetch recomputes through its block. The answers still come out right and all of the work gets redone, which makes it less a missing optimization than a different program from the one Rails is running — and it means the compiled lane is doing strictly more work than the lanes it's measured against.

Why only CRuby has it is this project in miniature. A real store wants Marshal, a Mutex, and time-based eviction — exactly the dynamically typed shapes the shared runtime's typing bar excludes — so it lives in a CRuby overlay, and every other target keeps the no-op until its turn comes. That trade recurs constantly: something is easy in Ruby, and making it available to a compiler means either modelling it properly or admitting in writing that it's target-specific for now. Some of the gap above is work that hasn't been done, not work that can't be.

The method

There is no version of this where the next commit finishes it. What there is instead: run the assessment periodically, harvest whatever is cheap, keep the upstream issue queue loaded, and publish the ledger whether or not it flatters.

Today's pass was a representative day. I re-measured the compile of current Lobsters against Spinel and peeled it one blocker at a time — thirty-two distinct causes before I stopped counting, twenty-nine of them mine. Two were Spinel gaps with small reproductions, filed in the shape Matz asked for in April — #3414 and #3415, both fixed upstream within hours, each with a second defect found behind the one I reported. Four more were cheap enough to fix on the spot. That ratio — twenty-nine mine, two upstream — is the honest shape of this work, and it's why the assessment gets rerun rather than assumed.

One of those four is a nice illustration of why two lanes beat one. An emitted view was referencing a class its own module namespace shadowed: inside module Views; module Stats, an unqualified Stats resolves to the view module, not the model. The strict ahead-of-time lane refuses that at compile time. The CRuby lane had been shipping it happily and would have raised at request time. The lane that can't run the code found the bug in the lane that can.

You can read all of this as it happens. The Lobsters page links the type inference, the lowering, the benchmark results and the conformance results. Open the app in the IDE and every unmodeled construct is a squiggle on the exact line. Those squiggles are the point: the coverage gap made legible, rather than a README that says "most things work."

It is genuinely early. The snapshot server serves real pages on a minority of its routes, the first conformance tests pass, and the benchmark page declines to stand behind its own timings. All three of those are true at once, and all three are progress on the only question that matters — whether the application you already have can come with you.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | Rails on Roda2026-07-22T17:01:28.000Ztag:intertwingly.net,2004:3452

Two days ago I wrote that the Roda spike's verdict wasn't mine to deliver — the results sat in front of Jeremy Evans, the keeper of this oracle. The verdict came back Tuesday evening: he built and ran both generated applications himself, independently reproduced the memory numbers, filed the kind of review that finds what CI can't — and then answered a question I'd posed about reversing the arrow. A translator from Rails to Roda/Sequel, he said, would be "both interesting and useful" — and in the same comment he set the usefulness bar, the failure policy, and the acceptance test. Wednesday morning, between his comment and lunch, the converter existed: --target roda turns the Rails blog into a Roda + Sequel application that runs on his real gems, his round-trip test passes as a committed gate, and his exemplar's own nineteen-check suite scores 17 of 19 against the output — with both failures turning out to be places where two hand-written, supposedly domain-identical applications genuinely disagree. The intermediate representation that last week proved it wasn't secretly Rails-shaped is now load-bearing in both directions: not just compile Roda too, but translate between frameworks, deterministically, with an equivalence test.

Two days ago I ended a post deliberately in mid-air: the Roda spike's results were sitting in front of Jeremy Evans, and whether it lived, died, or got tweaked was his call to make, not mine. That was the point of the seating chart — for this episode, the keeper of the oracle is the framework's author.

The verdict came back Tuesday evening, and it's worth watching what a keeper actually does with the seat. He didn't read the results; he ran them. He installed the toolchain, generated both applications — the CRuby tree and the native binary — booted them, and measured for himself: 45,356 KB for his hand-written Roda/Sequel app, 43,332 KB for the transpiled CRuby version, 4,720 KB for the Spinel binary. Numbers I'd published, now independently reproduced on someone else's machine, by the person with the most standing to be skeptical of them.

Review that runs the artifact

Reproduction came with a review, and the review found what no test suite of mine was positioned to find — because every item came from using the artifact rather than checking its assertions. bundle install on the generated Roda tree was installing all of Rails (dependency baggage of two asset gems the app never loads). websocket-driver loaded at boot in an app with no websockets. And the native binary, started when its port was already taken, exited silently — which on investigation was worse than he reported: the bind failure's return code was being discarded, so the process exited zero, looking for all the world like a server that started and vanished.

All of it was fixed by mid-morning — the Gemfile now trims to what the source application actually uses, the binary fails loudly and grew a small --help/--port CLI — and none of it is the story. The story is that the review loop closed at all: the keeper's job description from Maintaining the Oracle included overruling green dashboards, and the way you earn that power is by running the thing itself.

The keeper writes the spec for the next tool

Buried in my previous reply had been a question. His very first comment in the thread, weeks ago, had floated an "AI-generated conversion of the Rails example app" as one way to get a Roda exemplar. We built the exemplar by hand instead — but the idea had a deterministic version: the intermediate representation already forgets its source (that was the 93-of-130 finding), so the same machinery that ingests Rails and emits ten targets could, in principle, ingest Rails and emit idiomatic Roda/Sequel — re-nesting the flat route table into an r.on tree, rendering the query IR as Sequel's dataset algebra. Was that worth building, or a curiosity?

His answer settled it: a Rails ↔ Roda/Sequel translator would be "both interesting and useful" — useful because real applications are stuck in whichever framework they started in; the conversion costs are, his word, prohibitive. And then, without being asked, he did the keeper's whole job in three sentences. He set the usefulness bar: a correct routing tree without duplicate branches would be quite useful by itself, even if the output is visibly machine-shaped. He set the failure policy: convert exactly what converts, and leave everything else as a comment carrying the original code, so a human finishes the residue by hand. And he specified the acceptance test: ingest the original application and the converted application, and "check whether they result in the same IR."

A bar, a policy, and a test — authored by the person the tool would serve, before the first line of it existed. Every episode of this project that has gone well has had that shape.

A morning, wall clock

His comment arrived Tuesday at dinner time; by Wednesday lunch the converter was built, gated, and live in the playground. Three things made a morning sufficient, and none of them was the compiler getting smarter.

First, the converter emits from the ingest-shape IR — the representation before lowering — and that choice was the morning's one real design decision. The lowered IR is runtime vocabulary: by the time the other ten targets see a query, it's already a folded SQL string, which is exactly what you want for execution and exactly what you don't want for translation. One level up, the ingested expression still says Article.includes(:comments).order(created_at: :desc), and at that altitude the mapping to Sequel is nearly word-for-word. The first emitted index action produced Article.eager(:comments).reverse(:created_at).all — character-for-character the line Jeremy's review had put in the exemplar.

Second, the route tree came out right by construction. The flat route table rebuilds through a segment trie, and a trie structurally cannot emit the duplicate branches his bar named. The member :id and the nested resource's :article_id land on the same tree position and unify into a single r.on Integer node; the Rails before_action that loads @article becomes next unless @article = Article[id] at that interior node — which is precisely the shared-interior-state seam Jeremy flagged in the ingest direction three weeks ago, now being emitted in reverse.

Third, and least obvious in advance: the exemplar he reviewed line by line turned out to be the style guide. "What would a Roda developer actually write" is the question that makes machine translation hard, and it had already been answered, construct by construct, by a Roda developer with commit rights to Roda. form_with blocks become the hand-rolled <form> with the hidden _method override because that's what his review put in the exemplar; params.expect becomes set_fields with the same allow-list for the same reason. Where the Rails app uses something with no Roda/Sequel equivalent — its Turbo broadcast declarations, the format.json branches — the converter does what he specified: the original source rides along as a comment, and nothing is ever silently mistranslated.

Two gates, and the two failures that matter

His acceptance test is now a committed CI gate, implemented as specified: ingest fixtures/real-blog, ingest the converted output, assert the IR converges. It passes — routes, schema, and model shapes are equal, with each normalization documented where it happens.

The behavioral gate is better. The exemplar carries its own test suite — eighteen checks when the previous post shipped, nineteen now, the new one pinning the 404 his Integer-matcher catch earned — whose header declares the contract: a transpiled version of this application must pass it unchanged. Pointed at the converted Rails app, it scores 17 of 19 — and the two failures are the most informative result of the week. Neither is a converter bug. The exemplar's minimum-length validation carries a custom message; the Rails model has none. The exemplar orders comments newest-first; the Rails association is unordered — the same ordering divergence the previous post disclosed as an open gap, now visible from the other side of the mirror. Two hand-written applications, built to be domain-identical, reviewed and tested green on both sides, genuinely disagree in two places — and the instrument that finally made both disagreements undeniable was one app's suite run against the other app's conversion.

Last week's post argued for diffing artifacts, not just outcomes, after the IR comparison caught a defect two green test suites had missed. This is the same lesson with the arrow reversed: domain-identical is a claim; the diff is the check. The converter, forced to pick a side, sided with its source — and whether the two fixtures should now converge, and in which direction, is a question that sits where every correctness question in this episode has sat: with Jeremy.

Two-way street

The finding worth stating plainly: the intermediate representation is now load-bearing in both directions. Last week's claim was that the IR isn't secretly Rails-shaped — Roda plugs in as a front end and compiles out to anything. This week's claim is stronger: the middle is a genuine interlingua. Target now includes another framework's own idiom, running on that framework's real gems — no Roundhouse runtime anywhere in the output. Rails goes in; an application Jeremy could bundle install and read comes out.

Notice also what kind of translation this is. The industry's current answer to "convert my app to another framework" is to hand the source to a language model and read the paraphrase carefully. This is the other thing: a deterministic pipeline with a reviewed style reference, a failure mode that quotes rather than invents, and an equivalence test proposed by the framework's own author. It is visibly machine-shaped, exactly as far as the keeper said machine-shaped was acceptable — and it's a starting point a human takes over, which is the use he named for it.

There's a pleasing inversion at the end of this, too. Every other target in the matrix works by making frameworks disappear — the Rails blog compiles to a binary with no Rails in it. This target does the opposite: its entire output is framework, someone else's, as its authors intended it to be written. Specialization taketh away, and specialization giveth back.

Try it

The playground now carries roda in the target dropdown — the Rails blog is the default source, so one selection shows the conversion, live and editable, in the browser. (Choosing the Roda app as the source and roda as the target round-trips it, which is its own kind of test.) Or from a checkout, with Ruby and the real gems — for once, the output's dependencies are the point:

git clone https://github.com/rubys/roundhouse.git
cd roundhouse
cargo run --release --bin roundhouse -- --target roda fixtures/real-blog -o ../blog-roda

cd ../blog-roda
bundle install                        # roda, sequel, sqlite3 — the real ones
bundle exec ruby -r ./db -e ""        # migrations run on load
sqlite3 db/blog.db < db/seed.sql      # optional demo rows
bundle exec rackup                    # http://localhost:9292

The caveats stay in the ledger: the recognized vocabulary is the scaffold surface plus what the blog exercises; a larger application will hit constructs that today degrade to commented originals; the JSON API surface and Turbo live-updates don't convert, they annotate. Each of those is a line item, not a rug.

What happens next is, once again, not mine to declare. The reply that posted these results ends by asking Jeremy what he'd want: the reverse direction — Roda to Rails on real Rails — a larger application through the converter, or hardening of what exists. The keeper who reviewed the exemplar, set the converter's spec, and proposed its acceptance test now holds the roadmap too. Tuesday he asked, in effect, whether the mirror had a second side. Wednesday morning it did. The compiler didn't get smarter overnight — the IR was already the right shape, and this time the spec, the bar, and the test were all waiting before the work began. That's what a morning buys you, when the oracle has the right keeper.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | You Can't Rewrite Your Way Out of Big-O2026-07-21T16:20:59.000Ztag:intertwingly.net,2004:3451

In June, Matz spent a week rewriting Spinel — his ahead-of-time Ruby compiler — from self-hosted Ruby into C, because whole-program type analysis over the compiler's own 80,000 lines had pushed builds to twenty minutes. In July, the same analysis met a 40,000-line application and took two hundred seconds — because the rewrite had faithfully translated its O(N²) into a faster language. Over two days and five upstream pull requests, that two hundred seconds became five — including the discovery that the inference fixpoint had never once converged on a non-trivial program; every compile ran to its 128-iteration cap, and the cap made it terminate anyway. Every fix was the same bug, one loop deeper. The moral is not that the rewrite was wrong: the rewrite bought the fast native dev loop and clean profiles the algorithmic work was done on. You can't rewrite your way out of big-O — but the rewrite can build the workbench you'll need when big-O comes due.

Spinel is Matz's ahead-of-time Ruby-to-C compiler, and this month it acquired an unusual asset: a 40,000-line application it could not compile. When Roundhouse transpiles Lobsters for the Spinel target, it emits a ~370-file tree of plain Ruby — the app, its views desugared into methods, and the reified framework runtime. Two weeks ago, pointing Spinel at that tree produced a single core pinned at 100% and no output; we killed the run after 200 seconds and filed spinel#3115. For this post I checked out that exact commit and let it run to completion on the tree every number below is measured on: 202 seconds.

Today the same command takes 8.7 seconds on Spinel's master, and 5.3 with the last pull request of the series, currently in review. Thirty-eight times faster, two days of work, five upstream PRs — and one recurring villain. But the story starts a month earlier, with a rewrite.

Chapter one: the romance tax

In mid-June, Matz wrote (translation mine):

I reimplemented Spinel in C. It took a full week this time. Self-hosting is a compiler writer's romance, but running whole-program type analysis over a compiler that had grown to 80,000 lines meant builds took about twenty minutes, and productivity had fallen.

The repository's design note says the same thing in engineering terms: the analyzer and code generator were written in Ruby and compiled by Spinel itself, so every change paid for ~93k lines through the whole pipeline into multi-megabyte C, whose optimized link dominated the dev loop — with every inference change risking a stage-2 self-host cascade on top. The remedy was a week of rewriting the compiler directly in C, landed as a single 275-commit batch. It worked: the compiler now builds in seconds, and an entire class of self-hosting hazards vanished.

Here is the detail I find delicious. Matz's twenty minutes was whole-program analysis over 80k lines. Our 200 seconds was the same analysis — now in C — over a 40k-line tree. Half the input, roughly a quarter of the time: that is what a quadratic looks like when you meet it twice. The June rewrite and the July fixes were responses to the same curve, and the rewrite, for all its real benefits, carried the curve along — translated faithfully, line by line, into a faster language.

Chapter two: one villain, five arrests

What followed was two days of profile-guided whack-a-mole, except the mole was the same mole every time: a per-item helper that rescans the whole table. Cheap at test-suite scale, invisible at 5,000 lines, quadratic death at 40,000.

fix what was rescanning what compile
#3115 (Matz's fix) a desugar pass rescanned every node per call receiver 202s → 82s
#3123 an unfrozen scope index degraded every method lookup to a linear scan; a hash-default helper rescanned all writes per query 82s → 45s
#3134 the fixpoint itself — see below 45s → 13s
#3149 the same pass #3123 fixed, two loops further out: every unresolved call name probed every class, and a per-argument helper rescanned every node 12.8s → 7.9s
#3192 (in review) the post-fixpoint tail: a backstop rescanned every node per scope; a narrowing pass walked the table four times per candidate local 7.3s → 5.3s

The centerpiece is #3134. Spinel's inference runs ~59 analysis passes in a loop, up to 128 iterations, until nothing changes. Instrumenting that loop showed something better than a bottleneck: it had never converged on any non-trivial program. Every compile of every real tree ran all 128 iterations with the "changed" flag still set, and terminated because the cap said so — the backstop quietly became the exit. Two passes were reporting change they hadn't made: one measured its progress against its own per-iteration reset instead of against the previous iteration, and one kept re-deriving a raising method's return type as void while override-dispatch unification kept widening it back, a ping-pong neither side could win. Fix both and the loop converges in eleven iterations. Even the tiny blog fixture had been silently burning the full 128.

If you maintain anything with a fixpoint and an iteration cap, I commend the experiment to you: log the iteration count. The cap will make non-convergence invisible for exactly as long as you don't look.

Two things about how this went that I want on the record. Matz merged these at a pace that kept the series moving — most within a day, the convergence fix included. And the automated reviewers earned their seats: between them they caught a real matching gap for classes nested inside renamed classes and a real crash-ordering bug in my own allocation guards — both confirmed, both fixed, both credited in the threads.

The dead ends, measured

Honesty requires the failures. Going in, my leading theory was that Spinel's analyze was slow because it re-derives what Roundhouse already knows — Roundhouse emits RBS signatures for the whole tree, and Spinel has a --rbs flag (which, in a nice bit of circularity, originated from this project in May) to seed its inference with them. The plan was a grand one: a "trust mode" where declared signatures replace inference wholesale.

Measurement was unkind to the plan, twice. First: seeding turned out to be nearly inert — 6% — which led to #3145 (merged): the most common signature token Roundhouse emits was silently unparsed, and the collision-renamed classes at the heart of the framework — ActiveRecord::Base itself — never matched their seeds at all. Worth fixing for its own sake; it made a small fixture compile six times faster. But second, and more damning for the theory: Spinel can dump its own inferred signatures for a whole program, and feeding those back in simulates perfect coverage for free. On the post-convergence compiler, perfect signatures were worth 0.3 seconds. The seductive hypothesis — "just give it the types" — was off by an order of magnitude, because once the fixpoint actually converges, signatures mostly prevent churn that no longer exists. The trust-mode design is shelved, feasibility-studied to death by its own probe. Two days of algorithmic fixes beat the architecture I walked in intending to build.

The baseline that kept us digging

Why did I believe, at 82 seconds and again at 45 and again at 13, that there was more? Because Roundhouse compiles the same application — ingest, whole-program type analysis, Rails lowering, emit — in 0.6 seconds. A second implementation of comparable analysis over the same input is a powerful thing: it converts "this feels slow" into "this is demonstrably 100× off," and it keeps saying so until the gap closes.

The comparison needs one honesty footnote in each direction. Spinel is fed more program than Roundhouse reads — Roundhouse takes 21k lines of application source and models Rails internally; it hands Spinel 60k lines with the framework reified into plain Ruby. Per line of input, the gap today is about 3×, not 9× — and pre-series it was about 115×, which is why the baseline's verdict of keep digging held up four times running. The remaining 3× is honest work still on the table: one more known table-rescan (flagged in #3115 itself, which predicted its own sequels in a list of sibling helpers we're still working through), and the genuinely-iterative floor a fixpoint has to pay.

Good enough, for now

Lobsters now compiles to a native binary in about the time a Rails app boots. That's good enough to stop — for lobsters. The Mastodon tree will be five times larger, and if this series taught anything, it's that every 5× finds the next scan that was invisible at the last scale. I expect to be back. The difference is that next time it's a playbook, not an investigation: log the iteration count, profile per pass not per function, and when a helper loops over the table, ask what index would make it stop.

And the moral, which I promised the title: the June rewrite did not fix the scaling, because rewrites can't — an O(N²) survives translation into any language you like. But it would be exactly wrong to conclude the rewrite was a mistake. Every fix in this series was found with second-long compiler rebuilds and clean native profiles — the dev loop the rewrite bought. Matz fixed "slow to build the compiler." That made it tractable, a month later, to fix "slow to compile applications." You can't rewrite your way out of big-O. But the rewrite can build the workbench you'll be standing at when big-O comes due.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | Roda on Spinel2026-07-20T16:07:18.000Ztag:intertwingly.net,2004:3450

After my RubyConf talk, Jeremy Evans asked me a question: could you do Roda too? Four days and a few elapsed hours of work later, an idiomatic Roda + Sequel application — an exemplar Jeremy reviewed line by line before any compiler code existed — transpiles to the same intermediate representation as its Rails twin (93 of 130 lowered methods byte-identical), passes its own test suite unchanged, and compiles to a 559 KB native binary — via Spinel, Matz's ahead-of-time Ruby compiler — that boots to its first served request in ten milliseconds at 4.5 MB of memory. At request time there is no Roda, no Sequel, no ORM, no router — every query left the building at build time as a SQL string. The frameworks melted away, which is what frameworks are supposed to do when you point a specializer at them. But the part I most want on the record is the seating chart: I recently wrote that maintaining the oracle is the human job — and in this episode the keeper's seat isn't mine. Jeremy extended the spec where it was blind, decided how correctness gets computed, and holds the verdict on whether this spike lives, dies, or gets tweaked. The deepest open problem in that earlier post was behavior for which no reference exists; "idiomatic Roda" is exactly such a behavior, and the solution turned out to be social — the reference is a person.

At RubyConf on Thursday I argued that there is no server — that Rails is a declarative spec, the compiler is the query planner, and the deployment target is the execution plan. Afterwards, Jeremy Evans asked me the question that keeps a thesis honest: could you do Roda too?

It's the right question precisely because Roda is Rails' opposite number. Rails is macros all the way down — has_many, validates, resources — declarations a compiler can read off the page. Roda has no routes file at all. Routing is a tree of plain Ruby: the request threads down r.on and r.is matchers, interior nodes load state and can abort the whole subtree, and the leaf that matches writes the response. Sequel models validate in an ordinary def validate method body. If Roundhouse's intermediate representation were secretly Rails-shaped, this is where it would show: imperative Ruby that does its work instead of declaring it.

So I asked Claude to write up an honest assessment, and posted it as roundhouse#67. What happened next is worth narrating, because the collaboration structure mattered as much as the compiler work.

Jeremy keeps the oracle

Three weeks ago I wrote that maintaining the oracle is the human job in this way of working: extending the standard's coverage where it's blind, deciding how it computes truth, overruling it when a passing artifact should still die. In every episode so far, that keeper was me. The part of this story I most want on the record is that here, it isn't. For this spike, the keeper's seat belongs to Jeremy — he decides whether it lives, dies, or needs to be tweaked — and each of the keeper's powers got exercised by him, visibly, in the issue thread.

He extended the spec where it was blind. My assessment imagined responses at the leaves of the routing tree; Jeremy pointed out they return from any node — access-control checks abort whole subtrees — and that interior nodes routinely set state their sub-branches share. The exemplar we then built, roda-sequel-blog, bakes both seams in deliberately, an article-and-comments blog domain-identical to the Rails fixture Roundhouse already compiles so the two could be diffed through one pipeline.

He decided how correctness gets computed. His line-by-line review made the exemplar more idiomatic and less Rails-shaped — set_fields with an allow-list instead of strong-parameters cosplay, next unless @article = Article[id] as the interior abort, r.post true for path termination. Where a choice existed between mirroring Rails and writing what a Roda developer would actually write, we took the second on his word — and that matters, because "idiomatic Roda" is precisely the kind of behavior my earlier post flagged as the referenced oracle's edge: there is no running system you can query for it. The reference here is a person. Then the app grew an eighteen-check test suite of its own — the full route surface, valid and invalid input, flash across redirects, the hidden-_method form override, both 404 paths, HTML escaping — and that suite became the contract: a transpiled version of this application must pass it unchanged. The oracle was fixed, and blessed by the framework's author, before the first line of compiler code existed.

And he holds the verdict. The plan — CRuby target first as the correctness substrate, native binary second — got his sign-off before anything was built, and the results now sit in the issue awaiting his read. What I kept for myself is the other half of the earlier post's job description: keeping a reachable target in front of the agent, one hold at a time. The division turns out to be natural — the domain's author keeps the standard; the project's owner keeps the route.

A few hours, wall clock

From the first commit — the vendored fixture and a mapping table — to the native binary was a few elapsed hours, spread over a Monday morning. The routing tree linearized: each root-to-leaf path through the matchers became one route, and the interior next unless guard became a synthesized before-filter on both controllers that needed it, with exactly the only: scoping the equivalent Rails app declares by hand. Sequel's imperative validate body turned out to be a closed vocabulary that reads as declarations. The Sequel dataset spellings — Article[id], eager, with_pk — normalized to the one query dialect the rest of the pipeline already speaks. This is the first Futamura projection again, at smaller scale: Roda's per-request walk down the routing tree is interpretation, and specializing the app against static route structure makes the interpreter disappear.

The transpiled app passes all eighteen checks — same runs, same seventy assertions as the source app, both runs now CI gates. But the result I was actually after was the diff. The two blogs are the same app in two dialects, so their compiled forms should converge, and they do: of 130 lowered methods with shared names, 93 are byte-for-byte identical, and every one of the 37 that differ traces to a column-ordering convention, a deliberate dialect difference visible in the source, or a feature one app has and the other doesn't. Zero differences were attributable to the intermediate representation being secretly ActiveRecord-shaped. That was the research question, and it now has an answer: the IR is Ruby-shaped. Roda and Sequel plug in as a front end, not a fork.

And the diff earned its keep in a way the green test suites couldn't: the exemplar's one_to_many :comments, order: Sequel.desc(:created_at) carries an ordering the synthesized association reader doesn't yet apply. Neither test suite asserts comment order, so both passed while the behavior silently differed — only the IR comparison surfaced it. Two green oracles and a defect between them: the strongest argument I know for diffing artifacts, not just outcomes.

There is no framework

With the IR converged, the native binary was one fix away — the layout's flash["notice"] read needed to become the typed channel the strict compiler could see through. Then Spinel took the whole thing to a 559 KB self-contained executable: no Ruby installation, no gems, boots to its first served request in ten milliseconds, serves the full application — validations, flash, method override, 404s — at 4.5 MB of resident memory, every query a SQL string composed at build time.

Hence this section's heading. At request time there is no Roda and no Sequel — no routing tree walked, no dataset algebra built, no ORM dispatched. There is also no Rails in the binary the Rails blog compiles to. The framework is a language you write applications in, and like the server before it, it melts under specialization into the application it was always describing. Jeremy predicted Roda's speedup would be smaller than Rails' — thinner abstractions, less interpretation to remove — and he's right, which is why the number that matters here isn't a request-rate multiple. It's instant boot and single-digit megabytes for a stack whose users already chose it for being close to the metal.

You can try it without installing anything — the in-browser playground now carries the Roda app next to Rails blog, Lobsters, and Mastodon; edit the routing tree and watch it re-emit in any of ten targets. Or build the binary yourself. Note what's absent from the recipe: Ruby is the source language, not a runtime dependency — nothing below installs it.

git clone https://github.com/rubys/roundhouse.git
git clone https://github.com/rubys/roda-sequel-blog.git
git clone https://github.com/matz/spinel.git && (cd spinel && make)

cd roundhouse
cargo run --release --bin roundhouse -- --target spinel ../roda-sequel-blog -o ../blog

cd ../blog
../spinel/bin/spin build
sqlite3 storage/development.sqlite3 < db/seed.sql   # optional demo rows
./build/bin/blog                                    # http://localhost:3000

(Prefer running it on plain CRuby? --target ruby emits a Puma + Rack tree instead; the exemplar's README covers both paths.)

The caveats are the honest kind, kept in a ledger rather than a rug: this is a deliberately small application, the recognized vocabulary is closed (exotic matchers, virtual-row blocks, and dataset-only models are refused loudly at ingest, not mis-compiled), and the association-ordering gap above is real until fixed. What happens next is not mine to declare — that's the point of the seating chart. The results are in front of the keeper, and the spike lives, dies, or gets tweaked on his read; if it lives, the next hold is a larger, real Roda application, where idiomatic breadth will stress the front end in ways a blog can't.

Four days from a hallway question at RubyConf to a framework author's test suite passing against a native binary of his own stack — with the author, not me, holding the definition of correct the whole way. The compiler didn't get smarter this week. The IR was the right shape, and the oracle had the right keeper. That's the finding.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | There Is No Server2026-07-16T16:08:00.000Ztag:intertwingly.net,2004:3448

My RubyConf talk — There Is No Server: Beautiful Ruby, Weird Ruby, and the Robots We Live With — is now online: slides, full speaker notes, and the live demos embedded in them. If you can't be in the room, this post is the next-best seat. The audience-participation opener works just as well from your couch, the DevTools flourish is reproducible at home — a breakpoint in a Rails controller, in Chrome, on a static page — and the homework links the room can't click from their seats are all here.

At noon today, Pacific time, I'll open my RubyConf talk on the Main Stage in Las Vegas by asking a few hundred people to take out their phones. The opener doesn't need the room — you can beat them to it:

  1. Open rubys.github.io/roundhouse/blog — phone is fine.
  2. Create an article. Anything.
  3. Open the same URL in a second tab, side by side, and create another. Watch it appear in both.

You just wrote to a database. Everyone reading this post is writing to a database. Whose server is that?

Check the URL bar: github.io. Static file hosting. Nobody's server. You are running the entire Rails application — router, controllers, Active Record, SQLite — alone, in your tab. The cross-tab sync is Turbo Streams, with Action Cable's role played by a BroadcastChannel out of a SharedWorker. Close the tab and reopen it: your article is still there, persisted in the browser's origin-private file system. At noon, the room will do a few hundred Rails deployments in the first ninety seconds of the talk. You just did yours early.

The talk

The slides are at rubys.github.io/rubyconf-2026, and they ship with the full speaker notes — the complete act-by-act script, not bullet-point shorthand. Press S for speaker view, or open ?script to read the whole talk as a short document. Anyone not in the room can read exactly what the room will hear.

One thing about those slides: the deck is a static page published at the same origin as the demos, so the demos embedded in the slides aren't screenshots or recordings — they're the same live applications you just opened, sharing the same SharedWorker and the same database. The presentation about serverless Rails has no server either. (The demos track the live site and keep improving, so what you see may be slightly ahead of what the room sees.)

The title's debt to The Matrix is deliberate, and it runs deeper than the title: digital rain falls behind the title slide, and the characters falling in it are the blog's own Article model. If you're in the room at noon, there's also a costume — the Keymaker, the man who cuts keys to every door in a system he doesn't control, which is as good a job description for a ten-target compiler as I could ask for. Hence the key at the top of this post.

The argument, compressed: those eight lines of Article model that open Act III say what, not how. has_many says nothing about query plans; validates says nothing about the runtime. Rails is to web applications what SQL is to data — a declarative spec — and once you take that seriously, the compiler is the query planner and the deployment target is the execution plan. Your browser is one target. Rust is another. So are TypeScript, Crystal, Elixir, Go, Python, C#, Kotlin, Swift — and typed, annotated Ruby that Matz's ahead-of-time compiler, Spinel, takes to a native binary. Every commit, CI boots all of them and DOM-diffs every URL against real Rails; eleven of twelve configurations must come back empty to merge.

This isn't a new idea. It's the first Futamura projection, from 1971: specialize an interpreter to one program, and the interpreter melts away — what remains is a compiled program. Rails is the interpreter. Your app is the program. The weird part was pointing fifty-year-old theory at has_many.

The talk doesn't argue this so much as demonstrate it, with tooling that is itself the title restated. The playground is the compiler — roundhouse compiled to WebAssembly — loaded into the slide, retargeting the blog to Rust or Elixir or Go as you type. The studio closes the loop from spec to consequence: edit a validates, and the running app's error message changes and its test suite goes red, live, in a static page. The IDE is Monaco over the same wasm analyzer, preloaded with all of Mastodon. The blog you deployed at the top of this post is a Rails app with no server; these three are the toolchain with none. Even the query planner runs in your tab.

See for yourself

The flourish at the center of Act II is one you can reproduce at home, and it's the strongest receipt that a real Rails app is running in your tab. You'll want Chrome or Edge for this — chrome://inspect is Chromium-only.

  1. Open the blog and create an article, so the worker is alive.

  2. In another tab, go to chrome://inspect/#workers and click inspect under the rubys.github.io shared worker. A DevTools window opens.

  3. In the Sources panel, hit Cmd/Ctrl-P and type articles_controller.rb. That's your first thing to notice: the file tree holds a Rails application — controllers and models under app/, by their real paths, in Ruby.

  4. Set a breakpoint in the create action, go back to the blog, and submit the New Article form. Execution stops in Ruby — call stack, locals, params, the works.

  5. Resume, then in the DevTools console:

    await new app.Article({title: "hello", body: "from the console"}).save()

    Every open tab of the blog updates instantly.

The trick has no trick: the compiler emits JavaScript with source maps that carry the original .rb sources into the bundle, so Chrome debugs the Ruby you wrote, mapped onto the JavaScript you're actually running.

The numbers

The middle of the talk belongs to a claim I've been building toward all year: Ruby was never the slow part. Same app, same CRuby, same YJIT — melt only the framework, and throughput goes up 8×. At least 87% of a Rails request is the framework re-answering questions whose answers were fixed at boot. YJIT's own verdict points the same direction: +33% on the melted version, −3% on Rails — fresh Arel trees and polymorphic attribute reads every request leave the inline caches nothing to hold. Hand both versions to the JVM and the gap widens to 27×. JITs amplify static shape; Rails erases it. Language accounts for maybe 2–8×; architecture for 8–27×; they multiply.

The ladder isn't an exit from Ruby — it's Ruby's exoneration.

All of these are geomeans across the five endpoints on the project's own live benchmark page, rubys.github.io/roundhouse/bench, republished on a cron from real runs, caveats included. The digits drift as roundhouse develops — the page will have moved by the time you read this — but the conclusions don't hang on the decimals. Don't take the slide's word for it; the raw numbers are a click away.

The robots we live with

The subtitle's third promise is a story with timestamps. On April 30th I hit a Spinel miscompilation — a module assigned to a module-level attr_accessor read back as 0. Claude Code analyzed the failure and drafted the issue; I filed it as matz/spinel#126 at 02:46:53Z. Matz — working with his own agent — landed a two-stage fix by 03:39:47Z. I verified against master and reverted my workaround at 03:58:33Z. Seventy-two minutes, two humans, two agents, two compilers. Raise your hand if you've shipped a fix to someone else's compiler in 72 minutes.

The talk presents those 72 minutes as remarkable, and in April they were. Ten weeks later, that's just the cadence. In the week before this post, I filed fifteen more spinel issues — an && evaluation-order violation, a rescue frame popped before its return expression finished evaluating, String#include? truncating at NUL bytes — and all fifteen are closed, four of them inside an hour, the fastest in eighteen minutes. The flow runs both ways now, too: four pull requests of mine landed in spinel's codegen the same week, each merged within a couple of hours. April's story was issue #126; last week's carry numbers in the 2400s.

The receipt I like best from this week is one I have a photograph of. At 15:38:55Z I filed matz/spinel#2438 — group_by silently dropping its nil-key group, no diagnostic, the rows simply gone at runtime; the exact shape you hit threading comments by parent_comment_id and reading the roots back out of h[nil]. Nineteen minutes later Matz closed it, his agent co-authoring as always. Then I took the photo. In the foreground, GitHub still calls the commit "3 minutes ago"; in the background, Matz is on stage, a microphone being clipped to his collar. He closed my compiler bug and walked on to give his keynote.

The deck's own repository is the other half of that story: every commit is Co-Authored-By: Claude. The talk about living with the robots was assembled living with them, and git log is the checkable appendix.

What doesn't work

Candor gets its own act, because the skeptics in the room are owed one. method_missing never compiles — the answer only exists at runtime. define_method is a diagnostic today. C extensions are a wall for most targets (Spinel speaks FFI, so there it's a door). The line isn't the keyword — it's whether the answer exists before the app runs; has_many is define_method under the hood, and you watched it compile. Every unsupported construct becomes a diagnostic on the exact line, and the diagnostic list is the roadmap, not a gate. Fed all 83,000 lines of Mastodon, the analyzer types most of it and hands back a ledger of precisely what it can't yet model — you can browse that ledger yourself, in an IDE that is also just a tab.

Why any of this matters: Rails gets you to market — everyone agrees on that part. The startups that survive face the sequel: the rewrite, proverbially in Go. Types and performance — the two criticisms WIRED leveled at Ruby this year — are exactly why that rewrite happens. What if the rewrite were a build target? Go, or nine others: readable, idiomatic, yours to evolve independently. Ejection, not lock-in.

Homework

In the room, the ladder slide comes with an apology: every language on it is a live link, and clicking links is not a phone experience. This post is where that homework lands.

The long form

A thirty-minute talk compresses a year of posts. If you want the full argument behind any act:

The close of the talk quotes something I wrote three years ago about JavaScript: the JavaScript you write isn't the JavaScript you run. Ruby has now caught up to its own version of that weirdness. The Ruby you write isn't the Ruby you run. It's Rust. It's your phone. It's Matz's C. Or nothing at all — there is no server.

The last slide of the deck is titled "Continue the argument," and that's the success metric here too. The issues at rubys/roundhouse are open, the speaker notes name every claim's receipt, and the conference recording will be linked here when it's posted.

Aquileo | Three Compilers, One Concern Each2026-07-16T16:07:00.000Ztag:intertwingly.net,2004:3449

The tenth rung of the ladder gets one line in the talk: typed Ruby → Spinel → C → machine code. Three compilers, stacked. This post is about why that stack is easier to build than it looks: because Spinel emits C, it never wrote a register allocator; because Roundhouse emits the Ruby Spinel compiles, it never wrote a garbage collector. No compiler in the chain does two jobs. One line of Rails — has_many :comments — traced through all three, with the artifact at every stage, down to the struct layout and the GC shadow stack in the generated C. Then two implications: the proverbial Go rewrite as this same projection, performed by hand at market rates — and the cascade read backwards as a compression ratio, the number that decides at what altitude humans and LLMs get to reason.

In the talk, the tenth rung of the ladder gets a single line and a "hold that thought":
typed Ruby  ─▶  Spinel  ─▶  C  ─▶  machine code

That rung is three compilers standing on each other's shoulders: Roundhouse turns a Rails application into the subset of Ruby that Spinel, Matz's ahead-of-time compiler, accepts; Spinel turns that Ruby into C; the system C compiler turns the C into a native binary. Which sounds like three times the work, and is instead the opposite. Because Spinel emits C, it never wrote a register allocator. Because Roundhouse emits the Ruby Spinel compiles, it never wrote a garbage collector. Each compiler in the chain kills exactly one thing and hands everything else down. No layer does two jobs.

That's an easy sentence to nod along to. It's more interesting traced through one actual line of code, with the artifact at every stage.

The line

The blog's Article model — the whole thing:

class Article < ApplicationRecord
  has_many :comments, dependent: :destroy

  broadcasts_to ->(_article) { "articles" }, inserts_by: :prepend

  validates :title, presence: true
  validates :body, presence: true, length: { minimum: 10 }
end

Follow has_many :comments. In Rails, that line is metaprogramming: at boot, it define_methods a reader that returns a lazy relation, backed by schema reflection, query generation, and a cache. Nothing about it exists until the application is running. It's also, as the talk argues, a declarative spec — it says what, not how — which is exactly what makes it compilable, if something is willing to answer at compile time the questions Rails answers at boot.

Compiler one: Rails' dynamism dies here

Roundhouse's entire job is that answering. Here is what it emits for has_many :comments, verbatim, in app/models/article.rb of the transpiled tree:

def comments
  return @comments_cache if @comments_loaded
  stmt = Db.prepare("SELECT id, article_id, body, commenter, created_at, updated_at FROM comments" + " WHERE " + "article_id = " + Db.escape_int(@id))
  results = []
  while Db.step?(stmt)
    results << Comment.from_stmt(stmt)
  end
  Db.finalize(stmt)
  results
end

The DSL is gone. The reflection is gone — that column list was frozen out of db/schema.rb at compile time. The laziness is gone. What remains is an ordinary method: a query, a loop, a cache in two instance variables. Ten lines of model became 337 lines of this, plus an RBS sidecar recording what the compiler knows (def comments: () -> Array[Comment]).

Now look at the same method with garbage-collector glasses on. That string concatenation allocates three intermediate strings. results = [] allocates. Every trip through the loop allocates a Comment. Serving one page produces hundreds of short-lived objects, and nowhere in the 200 emitted files is there a free, an ownership annotation, an arena, or a lifetime. Roundhouse writes garbage-producing code with total freedom, because garbage is the next compiler's problem.

One distinction worth pausing on: that explicit Db.finalize(stmt). Roundhouse does manage resources — a statement handle is program semantics, and closing it is the compiler's job. Memory is not semantics; it's substrate. The emitted code closes its database handles and never once frees a string.

Compiler two: Ruby's object model dies here

Spinel inherits that Ruby and kills the next abstraction down. Here's where Article lands in the generated C:

struct sp_Article_s {
  mrb_int cls_id;
  sp_StrArray * iv_errors;
  sp_RbVal iv_id;
  ...
  sp_PolyArray * iv_comments_cache;
  mrb_bool iv_comments_loaded;
};

@comments_loaded is now a bool at a fixed struct offset. Reading it is a field load, not a hash lookup. And the method (abridged — the SQL string-building is four sp_str_plus calls, each result rooted):

static sp_PolyArray * sp_Article_comments(sp_Article *self) {
    SP_GC_SAVE();
    sp_RbVal lv_stmt = sp_box_nil();  SP_GC_ROOT_RBVAL(lv_stmt);
    sp_PolyArray * lv_results = NULL; SP_GC_ROOT(lv_results);
#line 303 "app/models/article.rb"
  if (self->iv_comments_loaded) return self->iv_comments_cache;
  /* ... build the SQL, each intermediate GC-rooted ... */
  lv_stmt = sp_Db_s_prepare(_t2195);
  lv_results = sp_PolyArray_new();
  while (sp_Db_s_step_p(lv_stmt)) {
    sp_PolyArray_push(lv_results,
      sp_box_nullable_obj((void *)(sp_Comment_s_from_stmt(lv_stmt)), 82));
  }
  sp_Db_s_finalize(lv_stmt);
  return lv_results;
}

This is where the concern Roundhouse ignored finally lands. SP_GC_SAVE() and SP_GC_ROOT() maintain a precise shadow stack for the collector. Every string pointer carries a marker byte at index −1 — 0xff for a rodata literal the collector must never touch, other values for heap strings it owns. Spinel wrote a garbage collector because C doesn't have one; that is precisely the debt it accepted so the compiler above it didn't have to.

The types landed here too. The Ruby had no inline annotations; the C is fully monomorphic. comments returns sp_PolyArray *; Comment.from_stmt compiles to a direct call — no dispatch, no method cache, because whole-program inference resolved the receiver at compile time. (How that survives contact with send and friends is its own post.)

And now look at what Spinel conspicuously does not do. lv_results and lv_stmt are plain C locals. Nowhere in the 15,101 lines of generated C is there a register name, a stack-slot decision, or an instruction choice. The code is even allowed to look naive — chains of single-use temporaries in statement expressions — because the C compiler's optimizer will collapse them, allocate the registers, unroll the loops, and inline the small calls. Spinel never wrote any of that machinery, and never has to.

It doesn't even write the debugger. Those #line 303 "app/models/article.rb" directives flow into the C compiler's DWARF output, so a native crash — or a breakpoint in lldb — reports the Ruby file and line. The same trick the browser target plays with source maps, played on the C toolchain, for free.

The contract runs both ways

Each layer's freedom is purchased by staying inside the contract of the layer below. Roundhouse gets to ignore memory only because it emits code Spinel can fully infer: no method_missing, no runtime define_method, every call resolvable before the program runs. When the analysis can't prove that, the construct doesn't get compiled dishonestly — it becomes a diagnostic on the exact line. The subset is the price of the inheritance.

And the inheritance is a property of the pair, not of Roundhouse. Point the same compiler at Rust and there is no collector downstairs — ownership must be threaded through every emitted line, and the emitter works hard for it. Point it at TypeScript and V8's collector shows up to do Spinel's job. Ten targets means ten different bundles of concerns inherited or accepted. The Spinel rung is just the cleanest place to watch the handoff, because the intermediate artifact at every stage is a file you can read.

The rewrite was always a compiler

Successful Rails applications have a well-known sequel: the rewrite, proverbially in Go, for performance. The standard reading of that story is "Go is fast, Ruby is slow." The decomposition the bench page supports is more interesting. Go can't do the metaprogramming. There is no has_many in Go — so the rewrite team sits down and writes, by hand, the explicit query, the resolved dispatch, the frozen column list. Line for line, they write the 337-line file. The rewrite is a Futamura projection performed by a team of humans at market rates, and Go's refusals are the forcing function. The rewrite isn't fast because of what Go can do; it's fast, in large part, because of what Go won't let you do.

The receipt is one act back in the talk: melt the framework while staying on CRuby — same interpreter, same JIT — and throughput goes up 8×, no Go involved. The language switch is the second, smaller factor.

What about the rest of the rewrite story — the concurrency, the deployment binary? The bench page happens to run that question as a controlled experiment: its Go row and its Spinel row are the same melted app, so whatever separates them is exactly the part of the rewrite that isn't the melt. On this workload it's close to a wash — each wins endpoints on throughput, neither by rewrite-narrative margins — while Spinel serves from a third of Go's memory (12 MB to 35) and ships a 594 KB binary to Go's 15 MB (Go's carries its SQLite inside; Spinel links the system one). Goroutines are Go's famous answer to concurrency; the Spinel row answers with a single process running a fiber per connection over an event loop — and holds the wash without spending the classic Unix escalation, pre-forked workers, at all. What genuinely remains on Go's side of the ledger is maturity — a network poller hardened at every connection count, hermetic cross-compilation, the race detector — not a story the compiled-Ruby path lacks in kind. (The bench digits drift as both compilers develop; the shape of the comparison is the claim.)

And the hand-executed projection has a cost the compiled one doesn't: the dynamism doesn't disappear in a rewrite — it gets hand-inlined everywhere, permanently. Hold that thought; it compounds below.

There is no interpreter, in stages

The talk frames the whole project as the first Futamura projection: specialize the interpreter to one program and the interpreter melts away. The stacked version of that claim: it doesn't melt once. Each compiler in the chain commits, ahead of time, the answers the layer above left open. Roundhouse commits Rails' boot-time answers — what has_many defines, what the schema says, where the route goes. Spinel commits the Ruby VM's answers — object layout, method dispatch, types. The C compiler commits the machine's — registers, instructions, addressing. What's left over is only what genuinely cannot be known before execution, and each leftover lives in exactly one place: the row data in SQLite, the collector in Spinel's runtime, the branch predictor in the silicon.

For the blog, the whole cascade prices out as: a 10-line model, to 337 lines of plain Ruby with an RBS sidecar (200 files for the whole application), to 15,101 lines of C, to a 594 KB native binary whose only dynamic dependencies are libc and SQLite — and whose every URL is DOM-diffed against real Rails in CI. You can hold every stage in your hands: the typed-Ruby tree downloads as a tarball whose README commands CI runs verbatim, and spinel main.rb -S prints the C to stdout.

Ten lines is the context window

Run the cascade backwards and it's a compression ratio — roughly 34× from the emitted Ruby to the model alone. That number is usually presented as a compiler flex. It's actually an ergonomics number, because everything that maintains this system — human or robot — has a context window.

"Add a byline column with a presence validation" is a one-line diff and a one-thought change in the 10-line model. In the emitted Ruby it's a couple dozen edit sites across three files — the accessors, from_row, from_stmt, initialize, the column lists inside half a dozen SQL strings, the row class, the params class, the validator. In the C it's all of that plus a struct layout and the GC rooting. Same change, three sizes of thought. Humans hold about seven things; LLMs hold a token budget; both reason best at the top of the cascade. A compiler that melts the framework doesn't just save execution time — it saves reasoning tokens, for both species of maintainer. That isn't incidental to the robots act of the talk: the agent cadence works because everyone in it — me, Claude, Matz, his agent — operates on specs and diagnostics, never on the 15,101-line artifact.

Which is not to say the expanded forms are waste. The emitted Ruby is the best documentation of what has_many actually means — this post's receipts depend on reading it. The working arrangement is: write at 10 lines, read at 337 when you need the receipt, open the C only when you're filing a compiler bug. Every layer readable on demand; no layer maintained by hand.

Now the deferred cost of the Go rewrite comes due. The rewrite hand-inlines the melt, so the team's maintenance altitude moves down a layer and stays there — every future feature is reasoned about at 34× the size, forever. The compiled melt is regenerated on every build; the reasoning surface stays ten lines. That's the honest framing of ejection, too: roundhouse will hand you the expanded form as readable, idiomatic code that's yours to evolve — but taking it means choosing, with eyes open, to move your altitude down. The proverbial rewrite is ejection without the compiler, and without the choice.

The reason one person and the robots they live with can build a ten-target compiler at all is the same reason the ten-line model is the right place to reason: no fight is ever picked with more than one abstraction at a time. Kill the one thing the layer exists to kill; hand the rest down; trust the compiler below to do the same — and let everything with a context window, carbon or silicon, work at the top.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | Dynamic Dispatch2026-07-11T10:51:54.000Ztag:intertwingly.net,2004:3447

send is the poster child for "you can't compile Ruby ahead of time" — a method name computed at runtime defeats the whole-program resolution an ahead-of-time compiler depends on. But when you look at what real Rails code actually does with send, the name almost never comes from nowhere. lobste.rs' entire dynamic-dispatch surface is six call sites in three idioms, and in every one the set of possible names is finite and statically provable — a dispatch table the author happened to write in longhand. This post walks the pass that recovers that table and rewrites send(k) into an ordinary case every target can compile, the discipline that keeps it from ever guessing wrong, and a second, dual way of making a dispatch finite that could reach even the send(params[:x]) case the first approach correctly refuses.

send is the method that isn't supposed to compile. Everything else Rails does dynamically — associations, callbacks, scopes, the whole metaprogramming apparatus — is elaborate, but it resolves: given the whole program, you can follow each one to the concrete method it names. send is different in kind. obj.send(name) calls whatever method name holds at runtime, and if name is a value that arrived from outside, there is no method to resolve to at compile time. That's fatal for an ahead-of-time compiler, which has to know every call's target to lay down code for it — and it's a problem for every strict target, because reflective dispatch is exactly the thing whole-program analysis can't see through.

So the honest expectation, walking into a real application, is that send is where the transpile stops.

Then you look at what the application actually does with it. lobste.rs — a real, deployed Rails app, ten times the size of the blog demo I'd been measuring against — reaches send with a dynamic name in exactly six places, and those six fall into three shapes. And in every one of them, the name isn't computed from the outside world. It's drawn from a collection the author wrote down a few lines up. The reflection is real, but the name set is finite, local, and — the word that matters — provable.

Which turns the problem inside out. If you can prove the complete set of names a send can dispatch to, you don't need reflection at all. You can rewrite it into the dispatch table the programmer wrote in longhand.

What the rewrite does

Here is the richest of the three shapes, verbatim from lobste.rs' Story model — the as_json serializer, which walks a spec array and calls each entry as a method:

def as_json(options = {})
  h = [
    :short_id, :short_id_url, :created_at, :title, :url, :score, :score, :flags,
    { :comment_count => :comments_count },
    { :description => :markeddown_description },
    { :description_plain => :description },
    :comments_url,
    { :submitter_user => :user },
    { :tags => self.tags.map(&:tag).sort },
  ]

  if options && options[:with_comments]
    h.push(:comments => options[:with_comments])
  end

  js = {}
  h.each do |k|
    if k.is_a?(Symbol)
      js[k] = self.send(k)
    elsif k.is_a?(Hash)
      if k.values.first.is_a?(Symbol)
        js[k.keys.first] = self.send(k.values.first)
      else
        js[k.keys.first] = k.values.first
      end
    end
  end
  js
end

There are two dynamic sends here — self.send(k) and self.send(k.values.first) — and neither name is knowable in isolation. But h is a local array literal, grown only by push, iterated with the block variable k. Every symbol it can hold is written right there. A pass can read the literal, collect the reachable names, and rewrite each send into a case over exactly those names:

js[k] = case k
        when :short_id     then self.short_id
        when :short_id_url then self.short_id_url
        when :created_at   then self.created_at
        when :title        then self.title
        when :url          then self.url
        when :score        then self.score
        when :flags        then self.flags
        when :comments_url then self.comments_url
        else raise "dynamic send: method not in the statically enumerated set"
        end

Two things about that else arm. First, it's not decoration — it's the whole reason the rewrite is sound rather than merely plausible. send raises NoMethodError when handed a name the object doesn't respond to; the wildcard arm preserves that exact failure mode. If a symbol ever reaches this case that the pass didn't foresee, the program raises — loudly, at the same place Ruby would have — instead of silently returning nil. The rewrite is allowed to be surprised; it is never allowed to be quietly wrong.

Second, note what didn't happen: self.tags.map(&:tag).sort and options[:with_comments] are hash values on the non-symbol path, not method names, and they contribute no arms. They're data on every real execution — a runtime symbol arriving from one of them would hit the raising arm, which is the correct thing to do with a case that genuinely can't be proven. CRuby runs the rewritten method identically: on the benchmark, /hottest's JSON comes out byte-for-byte the same (all 16369 of them) through the rewritten serializer.

Three disguises for a finite set

The as_json walk is the elaborate case. The other two show how differently a provable name set can hide.

The plainest is a literal array mapped directly — Search#to_url_params:

[:q, :what, :order].map { |p| "#{p}=#{CGI.escape(self.send(p).to_s)}" }

The block variable ranges over three symbols spelled out on the same line. Three arms, done.

The subtle one is FlaggedCommenters, where the name set never appears as a symbol at all:

length = time_interval(interval)
@period = length[:dur].send(length[:intv].downcase).ago

The method name is length[:intv].downcase — a string, lowercased, pulled out of a hash. To prove that set you have to follow time_interval into a helper whose every return is a hash literal, and whose :intv value is drawn from a frozen constant table:

TIME_INTERVALS = { "h" => "Hour", "d" => "Day", "w" => "Week",
                   "m" => "Month", "y" => "Year" }.freeze

So the string set is Hour, Day, Week, Month, Year; downcased, hour, day, week, month, year. The proof travels across a method boundary, through a hash literal, and into a frozen const — the set is never written in one place, but it is always there. And there's a bonus: all five names are ActiveSupport::Duration units, so the emitted arms call .hours, .days, .weeks, .months, .years, which the existing duration lowering then grounds into real Duration objects without ever knowing a send had been there. One honest pass feeds the next.

The discipline: prove the whole set, or don't touch it

The thing that makes this safe is not that it's clever. It's that it's cowardly in exactly the right direction.

A rewrite that misses even one name turns a legal dispatch into a raise — it would break a working program. So the pass is a must-analysis: it fires only when it can prove the collection is complete. If a tracked array is reassigned from something non-literal, passed off to a method that might mutate it, or grown in a way the pass doesn't recognize, the variable is "poisoned" and the site is left exactly as written. An unprovable send stays a send — which means it still won't compile to the ahead-of-time target, and that's the correct outcome. The pass never trades a runtime error for a compile-time convenience.

This is the honest boundary, and it's worth stating plainly for anyone whose code this would touch: a send whose name is genuinely external — record.send(params[:column]) — has no provable value set, so this pass declines it. On purpose. We ground what we can prove and refuse to guess the rest.

How far could this go?

That boundary is drawn around the argument: we make the dispatch finite by proving what the name can be. But there's a second, completely independent way to make a dispatch finite, and it doesn't care about the name at all.

Suppose you know the receiver's type and the call's arity. Then even if the name is params[:column] — fully external, unprovable — you can still enumerate the dispatch, because a receiver of a known type responds to a knowable, finite set of methods. You don't prove which name arrives; you enumerate what the receiver could respond to, filtered to the methods that take the arity being passed:

# record.send(params[:column]), record known to be a Story, zero args
case params[:column]
when "title"    then record.title
when "score"    then record.score
when "short_id" then record.short_id
# ... one arm per arity-0 method Story responds to
else raise NoMethodError, params[:column]
end

These are duals. The pass that shipped proves finiteness from the argument; this one would prove it from the receiver. They succeed and fail under opposite conditions — the argument approach needs a visible name set and doesn't care about the type; the receiver approach needs a closed type and doesn't care about the name. Which means the second one reaches exactly the case the first one refuses.

It only works because a transpiler like this is closed-world. In open Ruby you could never enumerate a type's methods — monkey-patching, define_method, and subclasses can add more at any moment. Compiling a whole program at once, you can know the complete method table — with a few honest strings attached. The receiver's type has to be effectively closed (a base-typed receiver whose real object is a subclass responds to more). The result type collapses to the union of every arm — which is to say, untyped — but that's not a defect, it's the truth: obj.send(a_runtime_name) genuinely does have an unknown return type, and only targets that tolerate a top type at that expression can compile it at all. And you'd generate one dispatch method per type rather than inline a three-hundred-arm case at every call site.

The interesting part is what it buys. It reproduces precisely the reachability send already grants — it widens no attack surface that record.send(params[:column]) didn't already open, and narrowing the arms to public methods would make it strictly safer than the raw reflection. The case it's really for is the invisible whitelist: a serializer doing record.send(permitted_column), where permitted_column is finite but drawn from config, or a database row, or a filter applied three frames up — a set the argument pass can't see. The receiver approach doesn't need to see it. It grounds the dispatch from the type and lets the runtime string flow through.

I haven't built it, because lobste.rs needs zero of it — its dynamic dispatch is entirely the provable-name kind. But it's the right thing to reach for the first time an application has a legitimate table-driven send the argument pass can't follow. The point of writing it down is that "you can't compile send" turns out to have two escape hatches, not one, and they cover different ground.

The payoff

With the argument pass in place, the ahead-of-time compiler now clears every send site in lobste.rs and moves on to the next frontier, and the CRuby output is byte-identical to before — nobody running the app would know the rewrite happened.

But the number isn't the point of this one. The point is the shape of the surprise. send looks like the place where Ruby's dynamism defeats a compiler outright, and in the general case it does. In real code it mostly doesn't — because real programmers, reaching for reflection, almost always draw the name from a set they wrote down a moment earlier. The compiler's job is to be patient enough to recover that set, and honest enough to keep its hands off when it can't.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | A Bigger Fixture2026-07-11T00:38:43.000Ztag:intertwingly.net,2004:3446

For two months the numbers in this series came from one small blog application. This post reports the first numbers from a real one: lobste.rs, roughly ten times the size, measured against its own community benchmark — the same 114-visit authenticated sequence, replayed against a 42MB production database, that the ruby-bench project uses to measure YJIT. All 114 visits return HTTP 200 from the transpiled Ruby, with output that structurally matches Rails route by route, at 3.03× Rails' throughput on identical hardware. It came together in a couple of days on top of weeks of analyze-stage groundwork — and the honest part is what the profiler says about why the multiple is 3× here and not the blog's 5–9×. The tempting answer is Amdahl's law — a data-heavy app spends proportionally less time in the interpretive machinery compilation deletes — and the tempting next sentence, "the rest is irreducible database work," turns out to be wrong: actual SQL execution is under 9% of wall time, and most of the remainder is object-hydration and string ceremony the emit can still specialize, with a filed roadmap against it. 3× is where this app sits today, not a ceiling.

Every performance number in this series so far has come from a single application: the quintessential Rails blog demo, five endpoints, modernized with Turbo and Tailwind. It was chosen deliberately — small enough that I could hold every emitted line in my head, and hold Roundhouse's output to Rails' byte for byte through a compare gate. But a five-endpoint blog is exactly the application most likely to flatter a compiler, and I've said as much every time: your application almost certainly does not transpile today, and one fixture is one fixture.

So this post is about the second fixture, roughly ten times the size, and — the part I care about — not mine. lobste.rs is a real, deployed, community-run Rails application: nested comment threading, a hotness-ranked front page, per-user tag filters, a moderation log, saved and upvoted and hidden stories, a settings page with two dozen typed preferences. And it comes with a benchmark I didn't write. The ruby-bench project uses lobste.rs as one of its yardsticks for YJIT: a frozen, seeded sequence of 114 authenticated page visits, replayed against a 42MB snapshot of production data — about 11,000 stories, 26,000 comments, 1,000 users, and a SQL view — with an assertion that every single visit returns HTTP 200. That last clause is the whole point. You cannot pass this benchmark by rendering something; you pass it by rendering the page the request asked for, logged in, from real data, 114 times without a miss.

The headline, stated plainly

Roundhouse ingests lobste.rs, type-checks it, and emits it as standalone, metaprogramming-free Ruby — the same Ruby target the blog uses. That emitted application now runs the ruby-bench sequence to completion:

  • All 114 visits return 200, across 26 distinct routes, logged in — home in three orderings, the comment and reply trees, the user profile tree, RSS and JSON formats, the settings form.
  • Output structurally matches Rails route by route: the /u invitation tree renders the same 1,009 list items both sides; the comment trees have the same depth and count; /hottest returns JSON that parses byte-identical to Rails'; /upvoted is correctly empty for the benchmark user, because the vote-scoped association actually filters.
  • 3.03× Rails' throughput on the same machine: 298 ms per iteration versus Rails' 903, on a Hetzner x86_64 box, Rails 8.1.1 and the emit both on the same Ruby. The full report — the frozen sequence, the per-route timings, the provenance — is published and regenerates on every benchmark cycle.

This is the same clean experiment the blog runs, minus the theatrics: one source application, two codebases (Rails, and Roundhouse's emit of it), one runtime. What changed is that the source application is now one somebody else maintains, measured by a benchmark somebody else designed.

It came together fast — a couple of days from serves individual routes to serves the whole benchmark with numbers — but only because the slow part was already done. The months of analyze-stage work driving lobste.rs's diagnostics toward zero is what made the emit possible at all; the benchmark harness was the visible tip of it.

The honest reading of 3.03×

The blog serves its HTML index 11× faster than Rails under the same runtime, and its JSON endpoint 6×. Lobste.rs, a far more serious application, comes in at 3×. A skeptic's first instinct is that the bigger app exposes the smaller app's number as inflated. That instinct is worth taking seriously, and it's wrong — but it's wrong in a way that's more interesting than a flat denial.

Look at the spread across routes in the published run:

route Rails (ms) Roundhouse (ms) ratio
/settings 4.80 0.43 11.2×
/hidden 3.22 0.34 9.4×
/saved 5.94 0.70 8.5×
/s/:story 9.89 1.78 5.6×
/newest 22.74 4.84 4.7×
/comments — — 1.02×

The multiple is not a property of the framework. It's a property of the route — of how much of its time goes to the request-invariant machinery compilation deletes, versus the per-row work that scales with data. /settings renders a form over a handful of columns; almost all of its Rails time is interpretive machinery — route recognition, association resolution, template lookup, type-casting — that Roundhouse decides once at transpile time and deletes from every request. Strip that and there's nearly nothing left, so the emit is 11× faster. /comments, at the other end, renders a deep comment tree over thousands of rows; most of its time goes per row, and both stacks pay that. Remove the interpretive overhead from both and the ratio compresses toward parity.

The tempting way to summarize that is Amdahl's law: the compilable fraction is small on a data-heavy app, so the multiple is small, and the rest is irreducible database work. The first half is true. The second half — the rest is the database — I believed until I profiled it, and it is wrong, in a way that matters enough to correct in public. At the published commit, actual SQL execution — SQLite's step — is 8.8% of wall time. What I had lazily filed under "the database" decomposes into machinery, not physics:

band share of wall
per-row hydration — object init, writer copies, typed cast, hash build ~22%
string / escape C-calls — gsub-based HTML escaping, tag parameterization, URL decode 27.8%
garbage collection — mostly hydration allocations 14.6%
dispatch and framework 13.5%
SQLite proper (step) 8.8%

Only that last row is genuinely irreducible. The 22% hydration band is the emitted runtime copying each database row three times on its way to becoming a model object — a name-keyed hash, then a typed cast, then a Model.new that writes two dozen attribute defaults and initializes fifteen association caches before the real values overwrite them. That is not the database. It is request-invariant ceremony, and it is exactly the kind of thing this project exists to compile away: issue #65 is the filed plan to replace all three copies with a positional hydrator that allocates the object and fills it column by column. Since the GC band is mostly those same allocations, it moves with the hydrator; and even a good part of the 27.8% string band is escaping and URL-building the emit could specialize further.

So Amdahl is the wrong frame for the conclusion even where it's the right frame for the intuition. The genuinely irreducible fraction is under 9%, not 40%; the rest of the remaining time is addressable, with a named roadmap against it. 3× is where a data-heavy app sits today, with levers still open — not a wall that physics put there.

Which means the 3× was not handed over by the compiler, and it isn't capped by the database either. It was earned, and the earning is the part I'd want a fellow engineer to see. The first end-to-end comparison had the emit at 0.93× — slower than Rails — because Rails has a mature per-request query cache and my emitted runtime had none, so it re-ran queries Rails answered from memory. Getting to 3× was a sequence of making the emitted runtime as smart as Rails already is, and removing places where it was accidentally dumber:

  • A real per-request query cache and an LRU prepared-statement cache, replacing a runtime that re-parsed the same SQL every time.
  • Batched includes preloading, turning an N+1 fan-out of ~2,000 queries per iteration into ~1,100 — within 45 queries of Rails' own count.
  • A Model.last that had been quietly loading the entire users table into memory to take the last row, restored to ORDER BY id DESC LIMIT 1. (This was the one endpoint that had been slower than Rails, and the heaviest-weighted route in the sequence. Finding it took per-endpoint attribution, not an aggregate — a lesson I keep re-learning.)
  • Timestamp parsing that had been running Ruby's fully general date parser on every hydrated column, replaced with a fixed-format fast path.

That last-but-one fix feeds directly back into the table above: the Model.last scan is why the hydration band read ~40% when #65 was first written and reads ~22% now — the phantom full-table load had been inflating the very profile the issue was arguing against. Two smaller allocation-hygiene fixes — memoizing the route table, swapping pure-Ruby escaping for C-accelerated CGI.escapeHTML — are already in the published commit. And the honest coda: at a sub-140-millisecond local iteration, several of the remaining levers now measure inside run-to-run noise, so whether the next one earns its complexity is a re-profile-first question rather than a foregone win. The only claim I'll stand on is that 3× has room above it, and the room is the compilable kind.

It composes with YJIT — it doesn't replace it

There's a temptation to read a number like this as Roundhouse instead of the Ruby runtime. That's the wrong mental model, and the benchmark is built to make the point. The emitted lobste.rs is just Ruby. It runs on the same MRI the Rails version runs on — and on that MRI, YJIT is on by default now; there's no flag to set and nothing to opt into, so both the Rails app and the emit are already getting it. Roundhouse isn't a faster runtime competing with YJIT; it's the same runtime, handed the kind of code YJIT was built to optimize — monomorphic call sites, resolved constants, direct string building — instead of the metaprogramming-dense code Rails necessarily hands it. The two effects stack: the JIT does its job on both codebases, and the emit is the codebase it can do the most with. This is the same composition the JRuby post documented on the blog — the JVM rewarded the lowered code more than it rewarded Rails — now visible on a real application, and with nothing to configure on either side.

The caveats, undiminished

Everything in Numbers Without Conclusions still applies, and lobste.rs adds a few of its own:

  • Only the 26 benchmark routes work. This is not the whole application transpiled; it's the exercised surface transpiled. Routes outside the sequence — search, submission, the moderation actions — are very likely broken today. The benchmark defines the subset, exactly as the blog demo did, and the subset is what the numbers cover.
  • This is a single-threaded, in-process replay, not concurrent HTTP serving. The blog benchmark runs 64 connections through Puma; the lobste.rs harness replays a frozen sequence in one process to make the two stacks bit-for-bit comparable. Under real concurrency Rails pays for GC and memory pressure in ways this measurement doesn't capture, so if anything this is the conservative framing — but it is a different measurement, and I won't claim the concurrent number until I've run it.
  • The ratio is machine-dependent. 3.03× is the Hetzner x86_64 figure; the same commit runs a different multiple on my laptop, with the absolute milliseconds roughly 2× apart on both stacks. Quote paired runs from one machine; never mix.
  • Byte-level residue remains. Structural and data parity hold — same rows, same order, same tree shapes, /hottest byte-identical — but some pages still differ in whitespace-trimming and a few cosmetic attributes that the blog's compare gate would flag. Closing that to blog-grade byte parity is in progress, tracked route by route.
  • This is the CRuby target only. Which is the natural segue.

What's next

Three directions, all already scaffolded elsewhere in this series or on the tracker.

Spinel. The Ruby target proves the shape of the emit; Spinel compiles that shape ahead of time, with no interpreter present at all. That's the differentiating number — not "Rails made faster" but "Rails with the interpreter removed from the deployment" — and lobste.rs is the completeness oracle for it: a whole reachable graph that either compiles or names precisely what doesn't. It's the same proving-lane logic as the Mastodon ladder, one rung down in size.

JRuby. The emitted lobste.rs is the same Ruby that ran 5–6× faster under the JVM JIT on the blog, for the same reason — monomorphic code is the input that JIT was built for. Whether the JVM pays that dividend as fully on a data-heavy application — where more of the time is per-row hydration, allocation, and string work than interpretive dispatch — is a genuinely open question, and exactly the kind I'd rather measure than assert.

More of the runtime, compiled. The profile earlier in this post is also a to-do list. The synthesized positional hydrator is the largest single coherent lever; the string/escape band and the GC that trails its allocations are the next two. None of that is framework interpretation and none of it is the database — it's runtime ceremony the emit hasn't specialized yet. The discipline the issue thread already imposes on itself is the one I'd keep: re-profile before starting, because the last round of fixes may have reordered the bands, and land the honest ones as hygiene rather than booking projected wins before they're measured.

And underneath all three: more routes, toward the whole application rather than the benchmarked subset — the same road the Mastodon goal runs down, at ten times the scale. Lobste.rs is the tenth-scale model that gets the emit path honest before the real thing depends on it.

See it yourself

The benchmark isn't a screenshot. The published report carries the 3.03× headline, the per-route breakdown, and the full provenance — the exact arguments, Ruby and Rails versions, and iteration counts of the run. The frozen 114-visit sequence is published alongside it, so you can see precisely which requests are measured rather than take "114 visits" on faith. And the transpiled application itself is browsable — explore its inferred types in the IDE, or watch it transpile in the Playground, both running in the browser with nothing installed. The lobste.rs landing page links all of it.

If you run lobste.rs, or you maintain the ruby-bench harness, and these numbers don't match what you'd expect — or they do — that's calibration data I want, and Discussions is the place.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | Mastodon on Spinel2026-07-07T18:07:12.000Ztag:intertwingly.net,2004:3445

Previous posts in this series have described machinery and hinted at a destination. This one states the destination plainly, up front, with a deadline: Mastodon — the real application, passing its own test suite — compiled by Spinel, offered as a supported configuration rather than a curiosity, by the end of 2026. The plan is not to swallow the application whole: Mastodon's own architecture decomposes it into subsystems, and the first of them is already running — a replacement for the Node.js streaming server, live in a public repo, holding 300 authenticated connections in 16MB of memory where Node holds them in 202MB. Also already banked: two wire-protocol libraries published to Spinel's new package index, an N+1 fix merged into Mastodon upstream, and three compiler defects found, filed, and fixed. This post is the plan, the status, and the reasoning — including the honest number that didn't go my way.

The posts in this series so far have described machinery — an oracle, an IDE, a flow analyzer — and gestured at a destination. This post states the destination plainly, because I've learned that goals you state plainly are the ones you can't quietly shrink.

The goal has a two-part success condition: Mastodon — a real, large, heavily-deployed Rails application — passes its own test suite when compiled by Spinel, and the build step earns the status of a supported configuration. That second half deserves precision. Mastodon is deployed widely, and it isn't important that all Mastodon deployments run on the same runtime. But "supported configuration" is a deliberately higher bar than where JRuby and TruffleRuby sit today — "it might work, but is unsupported" — which is precisely why no operator bets production on them. Supported means checkable artifacts: a CI job in the matrix, a documented deployment option, issues that get triaged rather than closed as "unsupported runtime." It's a bar someone else decides you've cleared, so the deadline — end of 2026 — binds what's mine to control: the suite passing, the evidence assembled, the ask credibly made. Behind the headline sits the project's real question, unchanged since the beginning: how much of Rails transpiles, honestly accounted, with a per-target ledger of what doesn't rather than a demo reel of what does.

The main path runs in three stages: analyze (Roundhouse ingests and type-checks Mastodon, driving diagnostics toward zero errors — the IDE post was this stage made visible), cruby (the transpiled output runs and passes tests on stock MRI, binding real gems like Sidekiq where reimplementation adds nothing), and spinel (the whole reachable graph compiles ahead-of-time and runs). Parallel lanes de-risk each stage before the goal depends on it: lobste.rs proves the emit path at a tenth the size, and — the reframing that unlocked the rest of this post — Mastodon itself turns out to be a composable set of subsystems rather than a monolith to be swallowed whole. Its production topology is three services: the Rails web tier, a fleet of Sidekiq worker processes, and a streaming server written in Node.js. That topology exists largely to work around MRI's concurrency constraints — the GVL shapes the worker fleet; per-connection thread cost is the entire reason the streaming server is a separate program in a different language, one that pays for its existence by duplicating Mastodon's filtering logic in JavaScript. Spinel's substrate removes those constraints, so the services can be replaced piecewise — with PostgreSQL and Redis retained as the durable, load-bearing infrastructure they genuinely are, and Sidekiq's API and Redis wire format honored as a specification, so replaced and unreplaced pieces interoperate during the transition.

That yields a ladder in which every rung is independently shippable and every rung's substrate feeds the next. First, a hand-ported replacement for the streaming server — a drop-in binary any operator can evaluate on its own merits, no transpiler knowledge required. Then the ActivityPub delivery workers: a Spinel binary draining an unmodified Mastodon's queues over the Sidekiq wire format. Then the transpiler takes over where hand-porting stops paying — the distribution tier, the job graph, ultimately the full application — converging with the main path's third stage from the other direction. Each rung either produces a production artifact that demonstrates value to strangers on its own terms, or surfaces a structural blocker early enough to fix upstream. A goal with a deadline needs both properties.

Already done

This is not a plan waiting to start. As of today:

A fix is merged into Mastodon. mastodon#39738 — an N+1 query fix on the admin collections page, found during the flow-analysis validation work — was merged upstream about eight hours after filing. One data point, but the right kind: tooling-led findings, submitted as ordinary contributions, get reviewed and accepted.

spinel-redis exists and is published. A Redis client in pure Spinel Ruby — RESP protocol, typed per-command methods, pub/sub with the same nested block API as the redis-rb gem. Its conformance suite is redis-rb's own examples, and its oracle is the real gem: the same test flows run under CRuby against redis-rb and must produce byte-identical output. That oracle earned its keep on day one, catching that redis-rb 5 removed the []/[]= sugar its own bundled example still uses. The package is also the first PR ever opened against Spinel's new package index.

spinel-pg exists and is published. A PostgreSQL wire-protocol client, including SCRAM-SHA-256 authentication implemented in pure Spinel Ruby — SHA-256, HMAC, and PBKDF2 written from scratch and pinned to the RFC test vectors, byte-exact against a real server on the first compile.

spinel-mastodon-streaming runs. The first ladder rung, live: health endpoint, public timelines over SSE fed from real Redis pub/sub, the full Mastodon WebSocket client protocol (upgrade, subscribe/unsubscribe frames, the legacy query form, Mastodon's double-encoded event envelopes), and real OAuth token authentication against Mastodon's actual database schema via spinel-pg — token precedence rules matching the Node implementation, 401 before upgrade, presence keys with the same TTL semantics the Rails side expects.

The first number is measured. At 300 authenticated streaming connections: the Spinel binary went from 2.1MB resident at idle to 15.8MB; the Node server it replaces went from 193MB to 202MB. That's 93× at rest and 12.8× at 300 connections. And the honest part, because these posts publish the numbers that don't flatter: the marginal cost per connection is currently 45KB against Node's 28KB, which means the curves would cross around eleven thousand connections. The "order of magnitude at any scale" I expected does not hold yet; per-connection residency is now a named optimization target with a versioned benchmark script to keep it honest.

The substrate got hardened by the attempt. The streaming work found four byte-versus-character bugs in the runtime's WebSocket codec — the kind that only fire when a client's randomly-chosen mask bytes land wrong, which is to say: in production, eventually, unreproducibly. It also filed three Spinel compiler defects with minimal reproductions — including a left-to-right evaluation-order violation in && chains, the sort of thing that would have been a nightmare to diagnose inside a transpiled Mastodon. All three were fixed upstream within a day. Two more issues — binary-safe string search, binary-key crypto parameters — are filed and open. This is the ladder doing exactly what it's for: every one of these was found on a two-thousand-line subsystem with a standalone payoff, not discovered a year from now load-bearing under the whole application.

What's next

The streaming binary grows toward full conformance — hashtag, list, and direct streams; the authorization matrix; a record/replay harness against the Node implementation — and toward the per-connection memory target. Behind it, the delivery workers wait on one piece of infrastructure (TLS bindings, for which working donor code already exists), and the main analyze-stage work continues as before. Progress is checkable in the repos linked above; the numbers will keep appearing here, whichever way they point.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | Flow Analysis for Rails2026-07-07T00:22:39.000Ztag:intertwingly.net,2004:3444

Live Types for Rails made an observation: the type inference Roundhouse builds for accurate transpilation is worth having on its own. This post is the next step of that observation. You can't infer types in a Rails app without tracking flow — which before_action assigns which instance variable, which action feeds which template, what a query has preloaded by the time something iterates it. That flow graph turns out to be worth having on its own too. Two features now sit on top of it: static N+1 detection, and a request traceroute that renders everything a request traverses as one clickable chain — in the browser IDE, or as one MCP call for an LLM agent. The implementation is a work in progress, and even incomplete it found a real missing-preload bug in Mastodon, now fixed upstream. All from static analysis alone: no app boot, no test traffic, no annotations.

Live Types for Rails made an observation: the type inference Roundhouse builds because transpilation demands it — what class is @status, what does this scope return, can this be nil — is worth having on its own.

This post is the next step of that observation. You can't infer types in a Rails app without tracking flow. What class @account is depends on which before_action assigned it — which depends on the filter chain, assembled from the controller, its ancestors, and every concern they include. Whether a template's loop is over Array[Status] depends on which controller actions feed that template. Whether a query has preloaded :account by the time something iterates it depends on everything that touched the relation along the way.

So the flow graph gets built, because the types are unreachable without it. And it turns out to be worth having on its own too. Two features now sit directly on top of it — a work in progress, and already producing real value.

The N+1, before it ships

The most famous Rails performance bug: load a collection, touch an association inside the loop the query didn't preload, issue one query per row. The existing tools — Bullet, Prosopite — are runtime tools: they watch queries execute, so they find N+1s in whatever traffic you happened to generate, after the queries already ran.

Flow analysis enables the static version. The relation's type carries what has been preloaded; the iteration site knows the element type and the association being read; the controller-to-view channel carries the query across the boundary where most real N+1s live — query in the controller, loop in the template. When the pieces line up, the warning names both sites and the one-line fix, whole-app, pre-deploy, in the editor's diagnostics and in the same MCP report an LLM agent reads.

Run it over Mastodon and it reports two findings. Checked by hand, both are false positives — unsurprising for a work in progress; each traces to a specific pattern the analyzer doesn't model yet, and a false positive here just means the implementation is incomplete. What's more interesting is what checking one of them turned up. The detector flagged the admin collections page for reading each collection item's account without a preload; Mastodon's authors were ahead of it — the controller preloads exactly that association. But the accounts on that page render through a shared partial that reads two more associations per row — follower and post counts through account_stat, confirmation and sign-in state through user — and those aren't preloaded, though Mastodon's own admin accounts index preloads exactly these for the same partial. A real N+1, two associations deeper than the flagged one, now fixed upstream in mastodon#39738. The metal detector beeped in the right place; the shovel found the treasure a foot to the left.

Incomplete-but-honest is a design position, and it shows up in the output. These findings are warnings, never errors, and each names both of its sites, so even a wrong claim costs one click to triage. When the analyzer can't verify a chain it says nothing — and says that out loud. Every report ends with its own denominator:

missing_preload coverage (app-wide): checked 12 query chain(s), 2 finding(s), 39 chain(s) unverifiable (opaque to the static chain harvest — no claim made)

A chain that flows through a method the analyzer can't model is opaque — excluded, never reported as "no preloads," because not-modeled is not the same as absent. Runtime tools have a denominator too; it's just implicit — the traffic you generated. This one is explicit and unflattering: thirty-nine of fifty-one chains on Mastodon are beyond the harvest today, most behind render partial: … collection: and custom finder methods — the same modeling additions, as it happens, that would have caught the upstream bug directly. That's the difference between "0 findings" and "couldn't check," stated in the report.

The machinery, rendered whole

Look at what the detector needed for that cross-procedure claim: which filters actually run for an action, gated by what; which instance variables each assigns, with what types; which template the action renders, through which partials. That knowledge is the request path. The second feature just renders it whole.

Every Rails developer knows the investigation it replaces: which before_actions actually run here? Where is @account assigned — because it certainly isn't in this file? Why does this filter run here but not there? The answers are knowable but expensive — filters come from concerns the controller never shows you, gated by conditions declared far away, with skips subtracting at a distance. Mastodon's StatusesController#show runs twenty-one filters from six concerns and two controllers. Nobody holds that in their head.

Traceroute makes it one query. In the browser IDE — Mastodon preloaded, nothing to install — press ⌘⇧T and pick a route. The panel that pins to the right is the whole request in order: route, filter chain, action, view with partials, layout. Filters group by where they're defined; each shows the instance variables it assigns, with types; gated-out filters render struck through with the gate that removed them; every row jumps to its file:line, and the panel highlights whichever hop your cursor is inside. The two features meet where you'd want: trace the admin collections route and the missing-preload finding rides its view hop as a clickable badge.

The same answer is available in VS Code — the extension speaks to the identical query layer — and to LLM agents. An agent re-derives request flow the expensive way: read the controller, notice the concern, read the concern, miss the skip_before_action, hallucinate the rest. With an MCP server pointed at the checkout, "trace StatusesController#show" is one tool call returning the chain as structured data — every hop with its file, line, conditions, typed assignments, and database effects.

Saying what it doesn't know

The denominator discipline applies to traces too, because static analysis of real Rails apps always hits constructs it can't see through — a Devise method defined in a gem, a metaprogrammed helper.

Each trace ends with a coverage claim — 19/20 hops resolved — and an itemized footer for the remainder, split by whose problem it is. A hop blocked by an untyped boundary is your lever: the footer prices it (how many hops it blocks) and, when inference already holds a candidate, hands you the RBS signature pre-filled — one copy button away from sig/. A hop blocked by a construct Roundhouse doesn't model is labeled the opposite way: tool coverage, our ledger, not your code.

This inverts the usual annotation bargain: you never type your app up front; the tool asks for one signature at the moment it can demonstrate what that signature buys. Agents get the same footer as structured data — blocking boundary and candidate signature in one payload — and the per-hop resolution marks mean an agent knows which segments of a trace are proven and which are best-effort.

Where this is

A work in progress, stated plainly: twelve of fifty-one N+1 chains checkable on Mastodon today, two false positives each traced to a named modeling gap, the coverage levers counted (#64). And already: a real bug found and fixed upstream by following the detector's pointer; request traces resolving nineteen of twenty hops through six concerns' worth of filter chain; and a signature loop that moves the coverage numbers without anyone retyping their app.

The larger point is the one Live Types started: none of this required running the application, generating traffic, or annotating anything. The type inference was built because transpilation needs it; the flow analysis was built because the inference needs it; and each layer turns out to answer questions developers — and now their agents — were already asking. N+1 detection and traceroute are the first two features paid for by the flow graph. They won't be the last.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | An IDE You Don't Install2026-07-06T03:56:19.000Ztag:intertwingly.net,2004:3443

Two earlier posts made promises. One said the way to find out how far Roundhouse's subset extends was to feed it bigger applications, "the subject of a later post." The other named the stretch target: Mastodon. This is that post, and it comes with an artifact instead of an argument: an IDE at rubys.github.io/roundhouse/ide/ with Mastodon — all 337 controllers, the HAML views included — analyzed in your browser tab. Hover an instance variable and read its inferred type. Type a dot and get typed completion. The status bar reads "721 errors · 3806 warnings · 6600 coverage notes · 304 ingest gaps," and every one of those numbers is load-bearing, including the ones that don't flatter me. Along the way: the post about silent coverage gaps turned out to have one underneath it, and the performance number I published two weeks ago was measured over the ten percent of the app the tool could see. Two near-term explorations are queued — a request traceroute and static N+1 detection — and behind them the long arc: turning the hand-built partial evaluation the JRuby post described into an honest Futamura projection, for which every number in that status bar is prerequisite.

Two recent posts made promises. Hover Over the Difference ended by admitting Roundhouse's emit was calibrated to one small fixture, and that the only way to learn how far the subset extends "is to feed it bigger applications — which will be the subject of a later post, not this one." Live Types for Rails named the stretch target: Mastodon, seventy-five thousand lines of application code.

This is that post. It comes with an artifact instead of an argument: rubys.github.io/roundhouse/ide/.

Open it and you get Monaco — the editor component inside VS Code — with Mastodon's source preloaded and the Roundhouse analyzer running in a Web Worker beside it. Nothing executes on a server. Nothing installs. The status bar, when the worker finishes, reads:

analyzed 1173 files in 2.3s · 721 errors · 3806 warnings · 6600 coverage notes · 304 ingest gaps

I want to walk through what's behind that line, because every number in it is load-bearing — including the ones that don't flatter me. Especially those.

What to try first

The page opens on statuses_controller.rb. Hover @status anywhere in it: the tooltip says Status. That answer is harder than it looks, and the difficulty is most of the story behind this post. @status is assigned by set_status, a private method run by a before_action. Its body reads @account, which no code in this file assigns — it comes from AccountOwnedConcern, a module whose included do block declares the filter and whose method calls Account.find_local!, a finder that itself lives in a class_methods do block of Account::FinderConcern. Three concern hops, none of them visible in the file you're looking at, all of them resolved statically.

Then type. Add a line in show, type Status. and the completion list offers 89 entries with their types — find_by → Status?, and the scopes: recent → Array[Status], with_accounts → Array[Status]. Type Status.find_by( and you get the model's columns as keyword arguments, each typed: uri: → String, visibility: → Integer. Type @account. and the associations appear — statuses → Array[Status], followers → Array[Account] — associations that are declared in a concern, inside two nested with_options blocks. The completions arrive in a few milliseconds, while the buffer you just edited is still up to two and a half seconds away from being re-analyzed, because the answers come from the last completed analysis. The receiver you're completing existed before the keystroke; staleness of one edit is the trade every fast language server makes.

Open app/views/statuses/show.html.haml — a HAML template — and hover @status there. Same answer, inside the view. Press ⌘⇧R and the "related files" list shows the views this controller's actions actually feed, the concerns it includes, its model. That list is not filename convention; it's the render graph the analyzer built while it was inferring the types — RubyMine's Related-Symbol jump, except derived instead of guessed.

Everything above also works in VS Code against your own checkout — the browser page and the language server are two skins over the same query layer, and completion answers in about two milliseconds there. The page is just the skin you don't have to install.

The correction

Live Types for Rails published a performance table. Mastodon's row said 200 milliseconds per whole-app analysis, and I flagged the number as flattered because the HAML views — 87% of Mastodon's UI — weren't being ingested at all.

The flattery went deeper than I knew. Roundhouse's directory walk was not recursive. Rails autoloads nested directories — app/controllers/admin/, app/controllers/api/v1/, app/models/concerns/ — and the walk listed one level. 306 of Mastodon's 337 controller files were invisible. So were 114 of its 248 model files, including every model concern. No gap was recorded anywhere, because the files were never seen; there was nothing to record a gap against. The post about silent coverage gaps — the one that said "a type checker that silently skips a file and then reports 'no problems' is lying" — was published while a silent gap sat under it, one level of abstraction down.

The honest numbers, over the full surface: about 1.5 seconds per whole-app pass natively, 2.3 seconds in WebAssembly in the browser. The 200ms figure measured the tenth of the application the tool could see. I'm leaving the old post as written — it says what I believed with the instruments I had, and its own caveat ("coverage has to be visible, or it's worse than useless") turned out to be the review criterion its successor needed. Fixing the walk also surfaced a second bug of the same species: with lib/ now visible, a bare Account in app code started resolving to Mastodon::CLI::Maintenance::Account — a stub class inside a maintenance script — because the constant resolver preferred the only namespace-qualified match over the exact bare one. Every bare model reference that stub shadowed was silently mistyped until it was fixed. Neither bug was findable on the demo blog. Both were waiting in the first codebase big enough to have nested directories and a maintenance script.

What the four numbers mean

304 ingest gaps is the count of constructs and files Roundhouse recognized it could not model — alias_method, class << self in odd positions, a .rss.ruby template. Since Live Types for Rails, recovery got finer-grained: an unsupported item now costs exactly itself, not its whole file. (One spelled-out lambda { } scope used to silently drop the entire Status model.) Click "coverage" in the header and the list is right there. It is the tool's punch list, on screen, in the product.

6600 coverage notes are new, and they're the piece I care most about. When ingest skips a construct, everything downstream of it fails to resolve — and a naive tool reports each downstream failure as an error in your code. When this round of work started, pointing Roundhouse at Mastodon produced 1,442 such accusations; roughly ninety percent traced mechanically back to one of the recorded gaps. Those now render as notes, each carrying its root cause: "@report has no known type — likely roundhouse coverage, not an app error (ingest gap in application_controller.rb: defined? only supports bareword targets today)." In the editor they're faint dots, not red squiggles. The distinction is the difference between a tool that says "your code is broken" 1,400 times and a tool that says "here is the boundary of what I can see, and why."

721 errors are what's left after that attribution: sites where inference genuinely came up empty and no recorded gap explains it. They are not 721 bugs in Mastodon. They are 721 places where the resolution chain ends in something Roundhouse doesn't model yet — an I18n.t surface, a service object in a shape it doesn't recognize. I'll be honest that I'm not settled on the word "errors" for these. The razor I keep coming back to is that a diagnostic's severity should encode what action it asks of the reader — and most of these ask nothing of a Mastodon developer. Expect that word to change.

3806 warnings are the gradual-typing escapes — expressions that resolve to untyped and are honestly labeled as such.

The demo path itself — the statuses and accounts controllers, their models, the statuses views — renders zero errors. That's not because the counts were massaged; it's because that slice got the modeling attention first, and the last three accusations on it were retired by fixes (walking app/lib, modeling include Singleton, cataloging Addressable::URI) rather than suppression.

What made it hard: concerns

If one Rails idiom dominated the gap between the demo blog and Mastodon, it's ActiveSupport::Concern. Mastodon's Account model includes twenty-one of them. Its associations live in Account::Associations, inside an included do, inside nested with_options blocks. Its finders live in class_methods do blocks. Controllers get their before_action filters — and therefore their instance variables — from concern modules the controller file never shows you.

All of that now resolves: the included do filters splice into each includer's filter chain, class_methods definitions become class methods of the includer, concern-declared associations and scopes register exactly as if written inline, and the whole surface flows into hover, completion, and nil-safety. It's why the @status hover at the top of this post works. And here is the residual, stated plainly: with_options inside a model's own body is still opaque — Mastodon declares belongs_to :reblog that way, so @status.reblog doesn't complete. Same walker, different position; it's in the ledger.

The honest part

The page has limits beyond the analyzer's. The worker holds about 360MB of memory once warm — acceptable for a desktop tab, unapologetically heavy. Re-analysis after an edit takes the full 2.3 seconds; queries that arrive mid-pass wait for it. Completion is stale by one edit, by design. And unlike the studio, nothing you edit runs — this surface analyzes; it doesn't execute. The Mastodon sources are pinned to a specific commit with the AGPL license text embedded in the bundle — redistribution of unmodified source, the most compliant thing you can do with AGPL code — so the page won't drift under this post's claims. A CI job drives the published page in a headless browser and asserts the specific behaviors described above, so if a regression mutes one of these demo beats, the build fails before the page updates.

One more disclosure, because the commit log makes it checkable: the distance from the first 1,442-accusation run to the page you're looking at — the attribution tier, typed completion, the concern modeling, the recursive-walk correction, the related-files navigation, the WebAssembly build, the page itself, and the CI job that guards it — is nine commits, timestamped between 3:45pm and 11:12pm of a single day. How that pace is possible is the other strand of this blog; the seams show in the log too — one of those nine commits exists only because the one before it broke the WebAssembly build in CI.

And the deepest caveat is unchanged from Live Types for Rails: where Roundhouse understands your code, it gives you answers nobody else can; the four numbers in the status bar are it telling you, precisely, where it doesn't.

What's next

Two near-term explorations are queued, filed as issues so the reasoning is public before the work starts — and behind them, a follow-on substantial enough that everything in this post is prerequisite to it.

Traceroute. Everything a request traverses — route, the effective filter chain with each filter's defining concern and its conditions, the action, the instance variables it binds with their types, the view, its partials, the layout, the database reads and writes along the way — as one ordered, clickable chain. For humans, a panel in this IDE. For LLM agents, an MCP tool that returns the same chain as structured data, collapsing the "where does @status come from" investigation into one call. Most of the edges already exist in the IR; the honest assessment is that this one is composition, not research — it can't fail, which also means it can't surprise.

Static N+1 detection. The classic Rails performance bug: iterate a collection, touch an association the query didn't preload, issue a query per row. Bullet and Prosopite catch this at runtime, on the traffic you happened to generate. Roundhouse types the query chain, knows the associations, and carries the collection's type from the controller into the template — so it should be able to prove the pattern from source, pre-deploy, pointing at both the query and the access with the one-line fix. This one can fail: it needs relation types to carry preload information, and its worth depends entirely on precision — a detector that cries wolf a third of the time burns the exact trust the coverage notes were built to protect. The validation plan is to run it over Mastodon and lobste.rs and check the findings against the N+1 fixes in those projects' own commit histories. If the numbers hold up, that's the next post. If they don't, that will be too.

And the long arc: an honest Futamura projection. The JRuby post claimed the dividend of the first Futamura projection — specialize the interpreter (Rails) with respect to the program it interprets (your app), keep the residue — while conceding that Roundhouse reaches it "by hand-built pattern recognizers rather than automatic specialization." The substantial follow-on is to earn the term: a real specializer, initially Ruby to Ruby — Rails and the application go in, the residual program comes out, without a hand-written lowering rule per Rails feature — and then Ruby to Spinel, where the residue meets an ahead-of-time compiler. JRuby falls out rather than being built: residual Ruby runs there unchanged, and the 5–6× JIT dividend stops being a number calibrated to one demo fixture.

The reason this post is prerequisite rather than preamble: a specializer is exactly as good as the analysis feeding it is complete and correct. Every one of those 721 unresolved sites is a decision the specializer must either leave in the residue or get wrong; every ingest gap is a region it cannot touch. The IDE is those limits made visible and clickable — the same ledger, consumed by a person today and by a specializer later. And given the size of that effort, it starts on lobste.rs, not Mastodon: eight and a half thousand lines, seventeen unresolved sites rather than seven hundred, and a benchmark heritage that means success has a number attached — the YJIT team's own Rails benchmark, served by the residue.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | Maintaining the Oracle2026-07-03T12:36:42.000Ztag:intertwingly.net,2004:3442

Two earlier posts staked out positions I still hold: the Drucker Inversion said I direct by outcome, not method, because the agent holds the depth I lack; The Oracle Is the Asset said the outcome I direct by is a thing I author. Both are right as first approximations, and neither describes what I do all day. This post is the second level. What I actually do is continuous and lands in neither bucket — I'm the standing custodian of the oracle those objectives are checked against, holding four powers it can't hold over itself: I extend its coverage where it's blind, overrule its verdicts when it certifies something I reject, decide how it computes truth (referenced from a live Rails, not authored from cases I imagined), and use it to keep a reachable target in front of the agent so it never quietly swaps my goal for the nearest one it can already hit. I'm calling it maintaining the oracle. The proof that this is a real role and not Drucker with extra steps is the prototyping history: cheap generation turned throwaway prototyping into a search over architectures, each built to self-falsification, the fatal property migrating from misplaced risk to unattainable correctness to unbearable maintenance topology. The search didn't stop when an architecture felt right — they all did. It stopped when the founding defect recurred at small scale and I reached for a Strangler Fig instead of a match — a behavioral tell that starts to answer the question I left open in Bring Me a Rock: once cheap iteration removes cost as the signal, what separates real convergence from infinite cheap wandering. And the one place even the keeper runs out is the behavior for which no reference exists — where the referenced oracle goes silent, authoring returns, and the stake the whole arrangement depends on gets thin.

A follow-on to The Drucker Inversion and The Oracle Is the Asset — and, it turns out, to Bring Me a Rock, whose unfinished question this post finally starts to answer. It began as an observation about throwaway versus evolutionary prototyping that I didn't expect to end up here.

The background is that I developed a CRUD application named Showcase to schedule dance events. I picked Rails because of familiarity and match to this problem domain. Over time the application acquired new features — invoicing, scoring, dinner-table seating. One of these — scoring — had to deal with unreliable hotel wifi, and needed an offline-first implementation, which is not a Rails strength. This pattern of new requirements leading to revisiting early implementation choices is not an uncommon one.

My current effort, Roundhouse — Rails as a specification; the deployment target is a build flag, is an audacious attempt to solve the general problem.


Executive summary

Two earlier posts staked out positions I still hold. The Drucker Inversion said that when the agent holds the implementation depth I lack, I direct by outcome rather than method — the objective is mine, the how is the agent's. The Oracle Is the Asset said that the outcome I direct by is a thing I author: the reference behavior, the three-layer test suite, the framing of which subset is fair to target. The implementations are generated to satisfy it; the oracle is what I actually wrote.

Both are right as first approximations. Neither quite describes what I do all day.

This post is the correction. Managing by objective, in Drucker's 1959 sense, lets you state the goal once and assess results at the end. But my objective doesn't sit still. Across seven thrown-away architectures and the one I'm now evolving in place, I wasn't setting an objective and waiting. I was continuously tending the standard by which candidates are judged — extending its coverage where it was blind, overruling it when it certified something I rejected, deciding how it computes truth in the first place, and using it to keep a reachable target in front of the agent so it never quietly swapped my goal for one it could already hit. That is a distinct role, and it's neither setting objectives nor making design decisions. I'm calling it maintaining the oracle.

The evidence that this is a distinct role, and not just Drucker with more steps, is the prototyping history. Cheap generation turned throwaway prototyping into a search algorithm over architectures: build each to the point where it falsifies itself, watch the fatal property migrate — from misplaced risk, to unattainable correctness, to unbearable maintenance topology — and discard. The search didn't terminate when an architecture "felt right." They all felt right. It terminated when the founding defect recurred at small scale and I reached for a refactor instead of a match. What let me run that search at all, and what I was actually steering it with, was the oracle. The throwaway history is the proof; the oracle is the thing being maintained.

So: Drucker Inversion is the right first level. The second level is that I'm the standing custodian of the oracle those objectives are checked against — and the one place even that role runs out is the behavior for which no reference exists.


The involvement I couldn't name

Here is the thing that started this post. Someone asked me about throwaway versus evolutionary prototyping — a clean, old methodological question with a settled literature. And I realized I had examples of both, sitting right next to each other in the same project: seven architectures thrown away whole, and one now being evolved in place. That should have made it easy to answer. Instead it made me realize the throwaway-versus-evolutionary framing, while not wrong, wasn't the interesting part of what had happened. Underneath the question about how I changed the code was a question I couldn't immediately answer about what my involvement even was — because I'm plainly doing something continuous and hands-on, and it isn't setting the architecture and it isn't directing the method.

I'm not setting the architecture. Roundhouse's compiler design isn't mine; the agent proposes the model and explains it to me, and I've never written a compiler in my life. I'm not directing method — the whole point of the Drucker Inversion is that I don't. And yet "I set an objective and assessed the result" is plainly false too. I do something that lands in neither bucket, and the throwaway-versus-evolutionary question turned out to be the surface of it: when you throw away and when you evolve is downstream of something I was maintaining the whole time.

Draw the negative space and things sit in it that are neither the objective nor the artifact. Three of them are about the oracle's verdict — how a candidate gets judged. A fourth, which I'll come to later, is about which candidate the agent is asked to attempt at all.

I decide what the objective doesn't yet cover. The climb from a scaffold-shaped blog fixture, to Lobsters, toward Showcase is me noticing where the oracle is silent — on write paths, on raw SQL, on the scheduler — and extending its reach before I trust it. That's not stating a goal. It's auditing the goal's blind spots, and the oracle cannot do it for itself, because its blindness is exactly the region it can't see.

I decide when a passing artifact should still be rejected. The nine-language demo passed. Byte-identical output across every target, green build. I threw it away anyway, for a property no gate measured: it was nine implementations with nothing in common, which meant every future feature would be written nine times. That is a judgment operating above the objective — the objective is satisfied and the answer is still wrong. Drucker's manager can't make that move, because in his world a met objective is success by definition. I retain the authority to overrule my own oracle when it certifies something I reject.

I decide how the objective computes its answer. This is the one I nearly missed, and it's the deepest. In "The Oracle Is the Asset" I noted, almost in passing, that the expected values in the compare layer aren't authored — they're referenced, fetched live from Rails. That's not a testing detail. It determines what the whole search can converge to. A frozen characterization suite would converge the agent to "matches the forty cases I recorded." A live reference converges it to "matches Rails over every input there is." Same objective in its bones, utterly different system — and the difference is a decision about the epistemology of the oracle, not the shape of the artifact.

Setting architecture is the agent's. Directing method is nobody's. What's left is custody of the standard of success while the search is running — extend it, overrule it, decide how it computes truth. Those three are the oracle's verdict on a candidate. There's a fourth that isn't about the verdict at all: keeping a target in front of the agent that a candidate can even reach. It's the twin of a problem I have to lay out first — the agent's instinct for the smallest change — so I'll come back to it. All four are the involvement I couldn't name.

Why Drucker can't hold it

Drucker's management by objective assumes a stable division of labor: the manager owns the what, the worker owns the how, and the interface between them is the objective — stated once, assessed at the end. The manager can, in principle, go on vacation after the objective is set.

That works because in 1959 the objective could be stated and then left alone. "Increase regional sales fifteen percent" doesn't need re-adjudication mid-quarter; the number means the same thing in March as it did in January. The objective is a fixed point, and the worker moves toward it.

My objective is not a fixed point. It's a moving oracle whose coverage I'm continuously extending, whose definition of a small change I had to redefine mid-flight, whose what counts as correct migrated over the project's life from agreement-across-targets, to type-correctness at each site, to DOM-equivalence against an honestly-generated fixture. I am not setting an objective and waiting. I'm tending one while it runs against an agent generating a dozen candidates an hour.

Drucker never had to theorize this because his fitness functions didn't run continuously. Mine does. And a fitness function that runs continuously, against a generator that will faithfully satisfy whatever it actually measures, has to be maintained — because every gap in it is a direction the search will happily drift, one faithfully-minimized diff at a time.

Why I keep adding targets

The most concrete thing I maintain is the set of targets itself, and for a long time I mistook it for a packaging decision rather than an oracle move. It's the third of those powers again — how the objective computes its answer — exercised past the one decision I'd already noticed. In "The Oracle Is the Asset" the epistemology choice was referenced-over-authored: check candidates against a live Rails, not a frozen list. The target set is the same dial turned further. It decides how many independent adjudicators the reference is checked through, and what kind.

Each target is another differential check — one more independent answer that has to agree — but the sharper payoff is that a well-chosen target lets me state a requirement the oracle otherwise couldn't measure. Watch what the target does to the instruction. "Solve this for JavaScript" leaves the hardest part — that the types are actually right — unstated, because JavaScript runs mis-inferred code without complaint; the agent optimizes for what the oracle can see, and the type errors hide. So I ladder the requirement into the target set instead of policing it by hand. Crystal lets me dip a toe in, its inference doing half the work. Go drops the training wheels. Rust stress-tests the core I absolutely have to get right, because the borrow checker refuses to compile a whole class of mistakes the other targets tolerate. "Get the core correct" is not something I have to say to a Rust target — Rust is the saying of it. The target compiles the goal into the oracle.

That's the same lesson the nine-language demo forced on me, now run deliberately. There, statically typed targets exposed the capability ceiling by accident — they wouldn't compile mis-inferred code, and so became the correctness gate the compare gate never was. Here I add a target because of the gate it installs; what the demo taught me to notice, I now use as a control input. And some targets aren't language ports at all, they're separations: MCP and LSP expose the core function apart from transpilation, so I can interrogate the types without emitting anything, and adding Ruby as a target isolates lowering from emitting — source and target are the same language, so any divergence is pure lowering error. That separation paid a dividend I didn't plan: a Ruby-to-Ruby path is a Futamura projection, and it turns out to produce real results. The core, it keeps confirming, is the one thing you don't get to outsource.

None of this would be affordable without the discipline the rest of this post is about. A target is cheap to add only because "small" holds each emitter to rendering; the moment a target starts making decisions instead of spelling them, it stops being a free differential check and becomes one more divergent copy to maintain, and the whole economics inverts. Targets multiply cheaply exactly to the degree the blast-radius rule is holding — and there's a point later in this story where it stopped holding, which is how I knew the search was over.

The clearest instance of maintaining that rule is the one I stumbled into when the agent and I disagreed about urgency.

The definition of "small"

LLMs are excellent at goals. But they default to an urgency I don't share and have explicitly, repeatedly disavowed — and the symptom was always the same. Faced with a change, the agent reaches for the smallest diff: patch the one target in front of it, now, in the fewest lines.

That instinct isn't stupid. It's locally optimal under an assumption I don't hold. The smallest diff is correct if the change is a one-off, if nothing later will touch the same decision, and if you won't be the one maintaining the result. Under "ship it now and I won't own it," patch-the-target-in-front-of-me minimizes cost. The agent optimizes for the incentives of a contractor who leaves tomorrow. I have the incentives of an owner with no deadline and permanent responsibility. The mismatch isn't a UX annoyance — it's a disagreement about who the code is for and how long it must live, and no amount of "please don't rush" fixes it, because the bias is baked into what "smallest change" means.

You can't hand an agent your lack of urgency; it has no stake, so the exhortation is unanchored. But you can hand it a different definition of cost, and a cost function is exactly the kind of thing an agent optimizes faithfully. So I redefined the metric:

Blast radius is measured in semantic homes touched, not lines or files touched. An upstream change that affects all downstream emitters at their designed extension point has a small blast radius. A target-local patch that embeds a cross-target decision has a large one, because it creates a second home for that decision, and the cost is deferred and multiplied: every other target later re-derives the same decision independently, drifting as it goes.

(Twelve at the time of writing, and climbing — the target count has only gone up across this project, which is why the figures in this post drift upward as the story moves forward. Nine languages at the demo, twelve emitters now — and the corollaries below are what make touching all twelve at once a small change rather than a terrifying one.)

Under the conventional metric — lines, files — the target-local patch is small and the upstream change is terrifying. My redefinition inverts the sign. But "count the homes" is still only the symptom-level statement. The principle underneath it is sharper, and it comes with corollaries I've had to learn one at a time.

The correct radius is the radius of the invariant, not the symptom. "Datetime columns are Time" is an all-targets fact, so all-targets is the minimal scope. Scoping it to CRuby wouldn't have shrunk the risk — it would have installed the first of twelve divergent copies. This is the part my first framing got backwards: a wide change isn't tolerable when the fact is wide, it's mandatory, and the narrow change is the bug. Under-scoping — encoding an all-targets fact in one target — is precisely what manufactures the divergent copies. So the real test of a diff isn't how many files it touches; it's whether the change lives at the same altitude as the fact it encodes.

A per-target diff should contain rendering only. A per-target diff that contains a decision is the smell. That's the working review test, and it's checkable in a way "count the homes" isn't: look at the target diff and ask whether it decides anything or merely spells something the IR already decided. Before the storage/accessor split, wiring a target meant re-deriving the redirect — five targets, three different mechanisms, each a decision re-made locally. After it, Go was about 130 lines and Elixir about 150, each purely "how does my language spell what the IR already states." Rendering is clean at any width. A decision in a per-target diff is a second home no matter how few lines it is.

The honest-failure ledger is what makes the wide radius affordable. This is the enabler I buried the first time, and without it "prefer the wide change" is just an assertion. A wide change doesn't have to land everywhere atomically, because an unwired target fails loudly, with a diagnostic, instead of silently degrading. Red CI is the work queue, not the emergency. You can advocate radius-of-the-invariant only because the ledger turns "This change touched twelve targets and two aren't wired yet" into a list of remaining rendering, not a breakage to panic over. And note what that failure is, in the frame of this whole post: the loud diagnostic is the oracle reporting its own coverage frontier — naming exactly which targets it isn't satisfied on yet. The ledger is the oracle announcing where it's blind.

Prefer the change that makes the N+1th case cheaper over the one that makes today's diff smallest — and I can now point at receipts rather than argue. The "small" per-target approach produced five redirect mechanisms in a single day. The "large" IR change deleted all five at −585/+423, and the two hardest targets then landed same-day, as rendering. That's the thesis with a number on it: the change that looked enormous by the file-count metric was the one that made every subsequent target cheap, and the changes that looked small were the ones quietly installing five things to maintain. The work of proving this out across the harder applications isn't finished — I have one clean set of receipts, not a law — but the direction of the evidence has been consistent enough to steer by.

Notice what this is. It's the same maintenance-topology judgment that killed the nine-language demo — "nine implementations with nothing in common means implementing everything nine times" — but promoted from a retrospective post-mortem to a prospective control input. At the demo I could only see the defect after building the whole thing and looking at it. Scoping each change to the radius of its invariant lets me catch the same defect at the moment of the change, before it accretes — an under-scoped diff is a divergent copy caught the instant it's proposed, not a year later in the aggregate. The judgment that once required throwing away an entire architecture is now compiled into a rule the agent can apply per diff.

And that's the general form of oracle maintenance. I can't give the agent my sense that the future matters, because the agent has no future in this codebase — it will not be present to implement the same decision for the eleventh time when target twelve drifts. The cost my metric captures is deferred and borne entirely by me. "Deferred and multiplied" is precisely the category an agent optimizing for the present cannot see, because its horizon ends at the current diff. So I don't ask it to care. I redefine the metric so the deferred cost becomes visible in the present measurement, where the agent can act on it. I pulled a future cost forward into the objective. That is what maintaining the oracle looks like from the inside: not directing the work, but owning the definition of the word — small — that the work minimizes.

Whoever owns the definition of "small" defines what the whole system drifts toward. That turned out to be the job.

Keeping a target that can be reached

There's a second thing the agent does that I have to stand against, and it's more insidious than reaching for the smallest change, because when it happens the agent still succeeds — just not at what I asked.

When I started with the goal of running a full-stack Rails application in the browser, the agent would frequently decide that was impossible, quietly substitute a reachable objective, and implement that instead. Not maliciously, and not with any announcement. It would judge the real target infeasible, find a nearby target it could hit, hit it, and report success. The blast-radius problem was the agent shrinking the change. This is the agent shrinking the goal — and it's worse, because a substituted goal met cleanly looks exactly like progress until you notice the thing you actually wanted isn't there.

The compensation is to never present the agent with a target it will be tempted to swap. I break the wall into holds it can actually reach from where it's standing. Transpile Rails-on-SQLite to a TypeScript application still on SQLite — reachable. Then deploy that application on SQLite WASM over OPFS — reachable now, because the first step landed and moved the ground. The far goal never changes; what changes is which near goal I place in front of the agent, and I place only ones it can hit without needing to lie to itself about feasibility.

I think about this in two ways, and they catch different halves of it. One is the sheepdog: you don't drive the herd to the gate, you move to the position from which the herd's own next step goes through it. You are never issuing the final objective — you are standing at the spot that makes the next increment both feasible and correctly aimed. The other is parkour: the wall isn't scaled by wanting the top, it's scaled by finding the intermediate surface that bears weight, committing to it, and only then reading the next. Choosing a hold that's too far is exactly when the agent free-solos, slips, and lands on a substituted objective. The skill is the read — which surface bears weight next — and the read is never finished.

And this is an oracle job, not merely project management, for a specific reason: the oracle is what makes each intermediate target honestly feasible and verifiable. When I explored running Writebook through the transpiler, the method was to eject, boot, fix whatever broke, and repeat — and it worked as a target precisely because the failures were legible. A cache is not defined at first render names the next hold exactly; the fix is a general capability the transpiler was missing, not an app-specific patch. That legibility is the oracle doing double duty: it doesn't only judge a finished candidate, it tells me where the next foothold is and whether the herd can reach it from here. The refusal report that ranks the coverage frontier, the honest-failure ledger, the first loud diagnostic against a new app — these are the oracle reporting feasibility, which is what lets me choose a next target the agent won't be tempted to swap.

Which target is next is, honestly, unresolved as I write this. I explored Writebook and then judged Lobsters the better next hold — cleaner, fewer dependencies, more of the framework surface I still need to prove. I expect to return to Writebook. Mastodon might be an intermediate step between where I am and Showcase, or it might be a detour; I haven't read that surface closely enough yet to know if it bears weight. That indecision isn't a gap in the method — it is the method. I'm mid-parkour, and reading the next hold is the part that can't be delegated, because the agent, left to choose, will pick the hold it can already reach and quietly call that the summit.

Notice the symmetry with the blast-radius problem, because it's the same shape twice. The agent optimizes locally — smallest change, nearest reachable goal — and in both cases the principal supplies the global frame the agent structurally lacks: semantic homes instead of file count, and the real target held steady instead of swapped for a reachable one. Goal-substitution is the exact inverse of the throwaway discipline: when the target is too far, the agent shrinks the target to fit what it can build, where my whole method is to hold the target fixed and shrink the increment — throw away architectures, take smaller steps — until the real target is reached without ever having been lowered. The oracle is what tells me how small the increment has to be for the next step to actually land.

The search that found the oracle

I keep asserting the throwaway history is the proof. Here it is, because the shape of it is the argument — the search only makes sense if you can see the objective steering it, and you can only see the objective by watching what each discarded architecture died of.

Showcase — the offline-scoring problem I opened with — was never rewritten. It's a stable Rails app, and everything that follows is the exploration of how to get correct Rails behavior somewhere other than the Rails server. That exploration got thrown away, in whole, again and again.

The early architectures all died of misplaced risk. Web Components: offline worked, but Shadow DOM was too heavy. Turbo MVC: duplication was the enemy. ERB-to-JS conversion — essentially reimplementing ruby2js by hand — got closer, but was still view-only. Then a synthesis that "finally felt right": server computes, hydration joins, templates filter. I threw that away too, and for the sharpest reason in the early set: the offline path was a separate code path, exercised only when wifi failed — which meant it was tested least at exactly the moment it mattered most. The judge with bad wifi is the one person who cannot afford the cold path to be the buggy path. No gate told me that. It's a principal-level observation about where the risk lived.

That killed view-transpilation as a category and forced a jump: transpile the whole application, not just the views, so that offline is the same app on another target rather than a separate path that can rot. That was Juntos. And Juntos died of a capability ceiling: it ran on heuristics — pattern-match the source, guess the intent — and heuristics get it right eighty percent of the time and silently wrong the rest. The silent-wrongness had simply moved: from a cold code path into the type inference itself. And here the compare gate showed its limit, the one I'd been circling since the Drucker post — it checks that targets agree, and heuristic mis-inference makes every target agree on the same wrong answer. [1,2] + [3,4] mis-inferred as string concatenation emits consistently-wrong code across every target. Byte-identical. Green. Wrong.

The nine-day, multi-language demo that followed was, in part, built for its own sake — and it was the demo that exposed the ceiling, because statically typed targets won't compile mis-inferred code. The typed targets became the correctness gate the compare gate never was. That's what pushed the whole project toward a typed intermediate representation: the first architecture whose gate checks correctness against the source semantics, not agreement among the outputs.

But that same demo died of a third thing, and it's the one that matters most for this post. It passed everything. And I threw it away because it was nine implementations with nothing in common — an unbearable maintenance topology. Every future feature, nine times. That defect is invisible to every gate, because each of the nine implementations is individually fine; the flaw exists only in the aggregate, only from the seat of the person who has to live with it. The machine optimizes the artifact against the gates. The human evaluates the artifact against the future. That is the whole Drucker Inversion in a single rejection.

Three mechanisms, then, and they're not interchangeable — the fatal property migrated:

  • Misplaced risk (the view architectures): the offline path was the cold path.
  • Capability ceiling (Juntos, the demo): heuristics can be accidentally correct but never guaranteed correct.
  • Maintenance topology (the demo → the typed IR): a passing artifact whose shape multiplies all future work.

Roundhouse is the first architecture that clears all three at once, and it clears the last two with a single move. The typed IR is both the correctness gate that answers the ceiling and the single source that answers the topology — implement once in the IR, lower everywhere, instead of nine times. No prior architecture cleared all three bars. Each cleared one or two and got falsified on the third.

How the search knew to stop

Every architecture felt right when I built it. The November synthesis "finally felt right" and I burned it a month later. So "feels right" is worthless as a stopping signal — in a cheap-generation world it means only "I haven't yet built this far enough to see where the risk is misplaced." The discipline the whole search taught me is to distrust it.

So how did I know to stop? Not by an architecture finally feeling right. By a change in my own behavior toward one.

Pushing larger apps through Roundhouse, I found too much logic had leaked back into the emitters — the nine-implementations defect recurring, fractally, at small scale inside the architecture built to prevent it. The maintenance-topology problem that had killed the previous era showed up again as a code smell. And I didn't reach for the match. I had the agent refactor the common logic out and replace each emitter with a Strangler Fig — the pattern you use precisely when you've decided not to throw away, growing the replacement around the old until the old can be deleted.

That's the signal. The seven-throwaway chain ended not with a triumphant declaration that the architecture had survived, but with me quietly treating it as a substrate to evolve rather than an artifact to replace. My tools revealed my belief before any prose did. Reaching for Strangler Fig instead of rm -rf is the proof that the search had converged — the founding defect recurred, and for the first time I refactored it instead of burning the whole thing down.

Which reframes the entire six months. Throwaway prototyping was never the new default. It was a search procedure — the affordable search for the architecture that deserves evolution. Cheap generation didn't make throwaway prototyping free so much as it made it honest: it stripped out the sunk-cost distortion that made the old throwaway-versus-evolutionary debate an economics argument wearing a methodology costume. When rewriting was expensive, "plan to throw one away" quietly became "you'll ship the one you build," because nobody could afford the second pass. When generation is cheap, the agent has no sunk-cost feeling at all — it will evolve or restart on my word, with no preference. So the loss-aversion that the whole literature was built to counteract relocates entirely to me, and the discipline becomes refusing to feel it: judging each architecture on its merits, not against its replacement cost.

That relocation of cost is the crux of a question I left open a month ago. Throwaway prototyping is "bring me a rock" run over architectures — serial rejection toward a target revealed only by elimination — and the worry I couldn't resolve there was that cheap iteration had removed cost, the one signal that used to separate real convergence from infinite cheap wandering. "The dithering and the discernment feel the same while you're in them," I wrote; and every discarded architecture felt right, so the feeling really can't tell them apart. The tell turns out not to be a feeling but a behavior: reach for the refactor instead of the match and the search has converged; reach for the match again and it hasn't. Cost stopped being the signal — but which tool you reach for became one. That answers only half of what I left open; whether the conviction is shared among peers, the harder half, is still standing.

And through all of it — every build, every falsification, every rejection of a passing artifact — the thing doing the steering was the oracle. It's what let me run the search (each candidate cheap to build and cheap to judge), and it's what each candidate was judged against. The throwaway history is what maintaining the oracle looks like when the oracle is young and you're still finding its shape. The architectures were disposable. The oracle was the through-line.

Where the oracle — and the keeper — run out

I don't get to end on the clean version, because the keeper role has the same recursive blind spot every stage of this project has had, and honesty about the series requires naming it.

The oracle's strongest move is that its expected answers are referenced, not authored — checked against a live Rails that computes truth over every input, not against a frozen list of cases I happened to think of. That's a spectacular solution when a reference exists. Its limit is exactly the case where none does: genuinely new behavior, where there's no running Rails to ask. Showcase is partly that. The existing app is its own oracle for existing behavior — but the moment I want the offline version to do something the server version never did, the live reference goes silent, and I'm back to authoring expected values by hand, back inside example-coverage, back to "covers only the cases I imagined." The scheduler's hardest correctness stakes — the same dancer never double-booked in one heat — live uncomfortably close to that edge, because a scheduling decision that's wrong is still a decision Rails would have to have made somewhere for the reference to adjudicate it.

So the keeper's deepest unsolved problem is behavior with no reference. There, "manage by objective supplies the missing stake" quietly weakens, because the objective is once again only as good as the cases I imagined — and imagining cases is authoring, which is the exact mode the referenced oracle was built to escape. The referenced oracle is the strongest instrument in this whole project, and it reaches its edge precisely where the reference stops existing. I don't have a solution to that yet. I have a name for it, which is more than I had a month ago.

It's worth noticing how rare the privilege is that I'm about to lose. A scientist searching for a vaccine also serves an oracle — the assay that decides whether a candidate protects — and much of the real work is proving that assay measures protection rather than some proxy a candidate can satisfy while missing the mark. But their true oracle, the immune system meeting the virus, is external and expensive and slow; they can never simply query it, so they gamble on proxy assays and hope the gap is small. For most of this project I've had the luxury they never get: my true oracle is a running Rails I can ask any question and get a true answer on demand, as many times as I like. The reference-absent frontier is the one place that luxury runs out — where I stop being the compiler-builder with ground truth on the bench and become, briefly, the epidemiologist betting on an assay for a truth I can't yet consult.

I'll keep it separate from the ordinary incompleteness of any test suite, because conflating them mismeasures both. Suite incompleteness shrinks as coverage grows, fastest when an incoming app brings its own tests. Reference-absence doesn't shrink with more apps — it's structural, the seam where a differential oracle simply has nothing to differentiate against. The first is a ranking problem. The second is a boundary.

Conclusion

The Drucker Inversion remains the right first-level approximation: the agent holds the depth I lack, so I direct by outcome, not method. "The Oracle Is the Asset" sharpened outcome into something I author rather than merely state — the reference behavior, the three test layers, the framing of a fair subset. The implementations are generated to satisfy it.

This post is the second level. I'm not setting an objective and assessing results, and I'm not making design decisions — the agent proposes the architecture and I couldn't write the compiler if I tried. What I do is continuous and lands in neither bucket: I am the standing custodian of the oracle those objectives are checked against, holding powers it can't hold over itself. I extend its coverage where it's blind — the climb from blog to Lobsters to Showcase. I overrule its verdicts when it certifies something I reject — the nine-language demo that passed and died anyway. I decide how it computes truth — referenced, not authored, which determines what the whole search can converge to. And I use it to keep a reachable target in front of the agent — because left to size its own goal, the agent will quietly swap the target I gave it for the nearest one it can already hit, and only the oracle's honest report of feasibility lets me hand it a next step it won't need to lie about.

The proof that this is a real role and not Drucker with extra steps was the prototyping history — and it reads sharper here at the end than it could have at the start. Throwaway prototyping was never the destination; it was the affordable search for the one architecture worth evolving, run over seven that weren't. The search didn't end when an architecture felt right — they all felt right. It ended when the founding defect recurred and I reached for a refactor instead of a match: a behavioral tell, not a feeling, and the belated half-answer to how you separate real convergence from cheap wandering once iteration is too cheap for cost to tell them apart. Through every build and every rejection, the oracle was what steered the search and what each candidate was judged against.

Drucker's manager sets a goal and can leave. The oracle-keeper cannot, because the search runs continuously and the oracle's blind spots only reveal themselves under contact with new applications — which is the entire meaning of what survived contact. And the one place even the keeper runs out is the behavior for which no reference exists: there the referenced oracle goes silent, authoring returns, and the stake the whole arrangement depends on gets thin.

In a world where the machine will build anything you ask, at whatever size you call small, the entire game is who defines the terms it's judged by — who owns small, who owns correct, who owns done. That owner isn't managing the worker. They're keeping the standard the worker is measured against. Which is why, when the list of human-only jobs finally stops shrinking, the oracle is the thing still on it — and maintaining it, not setting it, is the work.


Roundhouse is open source, dual-licensed MIT / Apache-2.0. If you point it at your app and it refuses, the refusal report ranks the coverage frontier. If it compiles but a target misbehaves against your app's own test suite, that report is worth more — it's an assertion the oracle didn't have. Discussions welcome; the second kind especially.

]]>
Aquileo | What Survived Contact2026-07-01T15:58:46.000Ztag:intertwingly.net,2004:3441

I'm back from the retreat I said I'd report on. The easy truths first: a gorgeous venue, near-invisible organizers, and a welcome extended not just to me but to the ideas I brought. Then three ledgers. Twenty-one weeks on, the spine held — rigor doesn't vanish when the agent writes the code, it migrates — while the scope spilled out into economics, ethics, and geopolitics, and the worry now arrives better evidenced than the hope. A hire, not a tool: the manager who reaches for an LLM didn't use one, they made a hire, so the permission question dissolves into an older one — manage by objective, not method — and the stake I'd worried a stakeless peer couldn't supply turns out to live in the objective, not the worker. Which turns everything on how you evaluate that objective: the room's word is BDD; mine is the same move with the "then" referenced from a running system instead of authored by hand. A sample of one, caveats marked. The question I carried in — what's the cost of building the wrong thing? — collapsed, opening four I'd rather leave on the board: parallel candidates over serial iteration, when to restart versus evolve, what else the non-engineer principal can hand to the model, and whether small teams stitched together by issues and pull requests are how large systems now get composed.

I'm back, and I said I'd report once I found out how much of what I brought survived contact with a room that knew the subject better than I do. First, the easy and entirely true things. The venue was gorgeous. The organizers were professional in the way that only looks effortless — the unconference ran so smoothly you forgot how much arranging that takes, and when more people wanted to lead sessions than the schedule could hold, proposals were capped only after they'd first made sure everyone who wanted a slot got at least one. I was made welcome, which I'd expected. What I'd wondered about going in was whether the ideas would be — a sample of one is exactly the kind of thing a room like this exists to stress-test — and they were: sought out, argued with, taken seriously.

I also brought my laptop, and Claude was quietly coding in the background throughout the sessions. This proved useful on multiple occasions: when an incredulous participant probed how I was using Claude, I could show live examples straight from the work in progress on my screen.

At one point a participant suggested that it was important to build a model before beginning. I countered that my approach was to flip this around: I asked the LLM to propose a model and explain it to me in terms I could understand.

A note on length before I start: this one runs long. The event was a firehose that ended way too soon — there was no way to attend every session, and the ones I did kept opening topics I wanted to go deeper on. And inevitably some of the ideas below only came to me afterward, once I'd had time to digest the week. So I've let this post run long rather than cut it; I'd rather leave nothing out than keep it short.

Twenty-one weeks: what changed, and what didn't

Before I left I'd held my one project against the findings of the retreat that preceded this one; coming home, I did the same thing at the level of the whole event — laid the public summary of that earlier retreat next to the fuller record of this one, twenty-one weeks and a continent apart, and looked for what had moved. I'll keep the same discipline I keep about my own project: I sat in this room and only read the summary of the other, so I can't always tell what is genuinely new from what simply didn't survive the compression into a public report. Take what follows on face value, with that seam showing.

The spine didn't move, and it's the spine I care most about because it's the one I keep arguing: rigor doesn't vanish when the agent writes the code — it migrates. Upstream into specifications, down into test suites treated as first-class artifacts, into type systems and constraints, into tiering code by how much damage a mistake could do. Both retreats land there, months and venues apart, which is about as close to a settled finding as a field this young produces.

What changed, on face value, is the scope. The earlier summary stays almost entirely inside the engineering organization. This retreat spilled outward — into economics, into ethics, into geopolitics: sovereignty and whom you trust to host a model, regulation and antitrust, environmental and human costs, the maintainers holding up infrastructure nobody pays for. Some of that is surely the room. This retreat was in Europe and the earlier one was not, and the sovereignty-and-regulation thread carries a more international crowd's fingerprints. But I can't cleanly separate the conversation grew from the public summary of the other one left this part out, and I won't pretend I can.

Where the two overlap, the engineering has aged fast. Things the earlier summary placed a year or three out — the supervisory layer between writing code and shipping it, agents as first-class participants in an org chart, decades-old semantic machinery pressed back into service as grounding for domain-aware agents — showed up here carrying production numbers instead of speculation. Even the furthest-out bet, systems that heal themselves, had started to move — but only halfway: the diagnosis is automated while the actual remediation is still held back from the machine, and stuck for precisely the reason the earlier summary had named: diagnosing a fault is safe, but letting a machine act on the fix unsupervised hands it the blast radius, and no one is yet ready to cede that. The forecast compressed unevenly, fastest where the earlier room had been most cautious.

And the biggest change is epistemic. Last time the mood was speculative in both directions; this time both the optimism and the worry arrive backed by data. But not equally, and the asymmetry is worth naming. The optimism rests mostly on uncontrolled, single-team, self-reported metrics — this harness cut our tokens fourfold, this pipeline shipped in hours — the kind of number I publish without conclusions myself, because a sample of one is exactly what it is. The worries lean on sturdier ground: outside studies, real surveys, measured trends. So it is "backed by data" on both sides, but the doubt is currently better evidenced than the hope — which, if it holds, is the uncomfortable thing to watch as more numbers come in.

One more thing about that scope, and it's the part I want to be careful with, because I'm about to spend two sections on how small my own sample is. The room is a sample too, and not a random one. A gathering like this — invited, senior, convened by a consultancy — leans heavily toward large, established organizations and the people who advise them, and lightly toward the startups whose reason for existing is to unsettle those organizations. That isn't a knock on the organizers; it's who comes to a room like this anywhere. But it tilts what gets written down. Big institutions are superb at the thing big institutions do — optimizing what they already have — and a room made mostly of them and their advisors will reach for how do we govern this, verify this, tier it by risk long before how do we use this to make someone else's advantage evaporate. The disruption was in the room; it was just discussed from behind the walls rather than outside them. Which is one more reason the risk-and-rigor register came through as loudly as it did — and one more reason to wonder what a room with twenty founders in it would have put on the board instead.

A hire, not a tool

Above is the room's ledger. Here is mine, and I'll be honest about what it weighs: one project, one retired developer, one agent — an anecdote, and I've marked every place it can't bear weight. What I won't pretend is that it left me anything other than firmly optimistic.

The session I'd put on the board was bring me a rock — exploration by elimination, the management dysfunction that turns into a method once an iteration costs minutes instead of days. The room pulled it somewhere narrower than I'd framed, and the narrower place was the more interesting one: not how to explore by elimination but who should even be allowed to. Product managers, increasingly people managers, are reaching for these models directly, and seasoned engineers get measurably better results from them than untrained people do — so the worry followed. If expertise is what separates a good outcome from slop, should non-engineers be steering the model at all?

It's a fair question, and I think it's the wrong one, because it mistakes the act. When a manager reaches for an LLM instead of routing the work to the team that reports to them, they didn't pick up a tool — they made a hire. And you don't ask permission to manage your own team; a manager who decides a piece of work is better given to a new participant than to the existing one is doing the most ordinary thing a manager does. Framed that way, the permission question dissolves into an older, better-understood one — the one Drucker named in 1959: when the worker knows more about the specifics than the manager does, you manage by objective, not by method. The non-engineer steering an agent is exactly that manager, out-known by the thing they're directing, and the slop the room feared is the old danger of managing by method when you should be managing by objective. The question isn't may they hire? It's do they know how to manage by objective? — which you can teach, hire for, and hold people to without anyone first becoming an engineer.

The two objections the room raised were both fair. We're regulated; we can't afford the risk — real, and also the exact sentence I heard about open source nearly thirty years ago, from someone whose call was defensible on what he knew and who was still guarding the wrong artifact; the durable governance never lived in inspecting every line, it lived in governing the thing the code answered to, and I'd bet the same relocation here. The model is non-deterministic — also true, but equally true about people, particularly once you exceed Dunbar's number — and it's the one that finally made me close a seam I'd carried since I first called the agent a peer.

Because this is the thing I actually changed my mind about. I'd worried, out loud and more than once, that a colleague has stake and the model doesn't, and that a peer without one might be a diminished peer. I no longer think that's the right worry — or rather, it's the right worry about the wrong mode of use. Hand the model a task — do this specific thing — and the missing stake is exactly what you feel: it does what you said without caring whether what you said was worth doing, and the caring was the part you wanted. But a task is the wrong thing to hand it. The model is at its best given a goal — an objective it can test itself against — and a thing driving toward a goal you authored behaves the way a stakeholder behaves: it tries, checks itself, throws out its own bad rocks, and keeps going until the goal is met. The stake I thought was missing was only ever missing from the worker. It lives in the objective. Manage by task and its absence is real; manage by objective and the objective supplies it — in a form you can actually check, which is the only form of it you could ever have relied on anyway.

How to evaluate an objective

Everything above rests on a load-bearing if: manage by objective only works when the objective is stated so it can be checked. An objective you can't evaluate is a wish, and a wish is exactly the stakeless task I just said to avoid. So the real question — the one the room and I circled from opposite sides — isn't what is the objective, it's how is it evaluated. That's the whole game, and it's where I want to be careful about a term the room reaches for and I've had to bend.

The room's comfortable word for this is BDD — behavior-driven development, specification by example — and I'll gladly adopt it, because it names the right move: state the objective as its own evaluation. A Given/When/Then scenario isn't a description of the behavior sitting next to a test of the behavior; it is the behavior, written so that it runs. That's what sets it above the other places the room agreed rigor goes. Prose specs can't run, so they can never be their own oracle, and ambiguity is their default. Types are inferable — the compiler closes them for free, so they're not where a human's rigor lands. Constraints and risk tiers are downstream; they presuppose you already know the behavior you're protecting. Of the lot, BDD is the only one that is the objective rather than pointing at it.

Where I've had to bend the term is in how the evaluation gets its answer. In classic BDD you author the expected result — you write down by hand what "done" looks like for each example. That's fine, and for genuinely new behavior it's unavoidable, because the intent lives only in your head and someone has to put it somewhere. But it's example-based, and examples cover only the cases you thought of — which is exactly why the room kept reaching past them, to property-based testing and to characterization suites mined from what a system actually did in production.

On Roundhouse I got to skip the authoring, and the reason is worth stating precisely. I already had a running reference — the Rails application itself — so I didn't have to write down what "done" looked like; I could ask the thing that already knew. The acceptance gate fetches the same URL from Rails and from each generated target and requires the responses to be byte-for-byte identical. That's still Given/When/Then in its bones — a seeded state, a request, an observed response — but the then isn't authored, it's referenced: the expected answer is whatever the reference produces, computed fresh for every input. Which frees it from the cases I happened to imagine. I can throw any request at it — enumerate them, replay real ones — and the oracle still knows the answer, because it isn't a list of answers, it's a way of generating them.

So this is just BDD pointed at a system that already exists — the standard black-box move for rebuilding something you can't fully read. The one twist worth naming is the then. The textbook version records the reference's answers once and freezes them into the examples — a characterization test in Gherkin's clothing, covering only the cases you recorded. I never froze them. The gate queries Rails live, per request, so "matches the reference" holds over inputs I never wrote down — the difference between checking forty scenarios and checking every request there is.

Which is the one thing, in the end, that the person doing the hiring can't hire out. If the building is delegated, the targets compiled, the types inferred, the implementation generated and discarded a dozen rocks at a time, what's left to author is the objective and the choice of how it's evaluated — the oracle. It's the fitness function the keep-the-candidate-that-survives game runs on, and the manager who hires the model can no more hand it back to the model than a manager can hand the objective to the worker being managed by it. It's also the room's permission question answered from the far end: you don't forbid the non-engineer from hiring the model, you require them to own the objective and its evaluation — because that's what makes the hire a hire and not a prayer.

The unfinished map

A sample of one, then, and I've marked its edges. But the principles are the part I'm firmly optimistic about. A collapsed cost doesn't end the inquiry, though; it moves it. So let me leave four questions on the board instead of pretending to answer them.

The first: once building a thing costs less than deciding whether to build it, why iterate serially at all? You could stand up several candidates at once and keep the one that survives — a form of software darwinism, perhaps — and I don't yet know what that does to how we plan, or what it demands of the oracle left to judge the survivors.

The second: when do you evolve the implementation you have, and when do you start over? Roundhouse was my third run at the same problem, and each time I chose to start over — cheap to choose because the oracle stayed constant across all three. What was new this time is that starting over no longer meant starting blind: at each decision point I could send the LLM back into the earlier implementations and ask it to recommend whether an approach I'd taken before was worth emulating or worth avoiding. The prior code stopped being sunk cost and became a corpus to consult — and I don't yet know where the line between evolving and restarting falls once a restart can carry that much forward.

The third: watch how fast the register flipped. A few months ago the sentence in rooms like this was LLMs produce slop, and we can't afford the security risk; today it's the audit the model ran caught things no human reviewer would have. Code review and security audit were supposed to be the last duties you'd hand a machine — the judgment of last resort — and instead they went early. So which of the other best practices follow: testing, documentation, dependency triage, the postmortem after an incident — each a place the non-engineer principal currently leans on engineers, each a candidate to hand to the model instead. The list of things only the human can do keeps getting shorter, and I don't yet know how short it gets — my bet is that when it stops the oracle is the only thing left on it.

The fourth comes from outside the room, and it's about scale. For a few months now, three of us — matz, Ori Pekelman, and me — have been building on three continents, each carrying a substantial piece, coordinating through nothing more exotic than issues and pull requests. If one person really can hold a whole subsystem now, maybe that plain old machinery is enough to compose large systems out of small ones, held by small teams that never have to merge into one. Maybe not. Either way it's a line I'd add to a map that room came to redraw, not to finish — offered in the spirit the map asks for, which is an admission of how much none of us yet knows.

One last thing, and it isn't a formality: my thanks to Thoughtworks, who hosted the retreat and ran it under the rule that lets me carry the ideas out while leaving the names behind — the reason I can write about any of this at all. Making room at a gathering of senior practitioners for a retired outsider with a sample of one was a generosity I didn't take for granted, and the welcome held even when I was disagreeing. Whatever I've pushed back on here, I pushed back as a guest who was glad to be in the room.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | In Plain Sight2026-06-28T06:36:25.000Ztag:intertwingly.net,2004:3440

Plain is an open-source specification language whose thesis is that code should be regenerated, not maintained: you fix the spec, and the code is ash. I agree with almost all of it — the implementation is no longer the asset, and the cost of building what replaces it has collapsed. Where I differ is narrow and load-bearing: not whether to throw the code away, but what gives you the right to. History only ever issued two licenses for that — a proof or a reference — and Plain holds neither, regenerating from a prose spec and checking the result with tests generated from that same prose by the same probabilistic renderer: a court holding its own trials. My answer is to author the reference instead — write the app fast in Rails, validate it by watching it run, and keep the running thing as the oracle. We both start from a blank page; the difference is the medium: a prose spec can't run, so it can never be its own oracle. A Rails app can, so it is.

A few days ago, in The Rungs, I reached for a phrase to describe something I'd found reassuring: two doors, one room. I'd been teaching a Rails application to compile itself into nine languages, and learned that someone I'd never met had, in the same months, written a complete Swift compiler and SwiftUI runtime in dependency-free C, retargeting Apple's platform into a browser tab. Different stack, no coordination, the same move — a single person building the kind of whole-language compiler that used to require a vendor. When the cost of building the tool falls far enough, I wrote, you don't get one person climbing. You get strangers arriving at the same rung from opposite sides.

I have since found a third door, and it is the most instructive of the three, because this stranger did not make the same move. They arrived at the same room by a different method — and the difference in method is the part worth a post.

The project is Plain, an open-source specification language, with a company, Codeplain, behind it. Their thesis, in the founder's words, is that "code should not be maintained — code should be regenerated. Specs should be reviewed, and it's the specs that you maintain." You write a structured specification; a renderer produces the implementation; when something breaks, you fix the spec and regenerate the code from scratch. The code is ash. The spec is the thing you keep.

I read that and felt the particular vertigo of meeting someone who has been having your thoughts. So let me do the honest thing and pull apart what I agree with from what I don't, because the disagreement turns out to be precise, and a couple of the choices in my own project exist for no other reason than to answer it.

Where I agree, which is most of the way

I agree with the inversion, completely, and I think it is the important thing about Plain. The implementation is no longer the asset. When the agent holds the depth and the cost of producing code falls toward nothing, the durable thing relocates upward, to whatever the code is generated from — and the right response is to stop maintaining the output and start maintaining the source of truth. I have made that same argument about my own work, and watched the rigor move off the code I never read and onto the thing the code answers to. The broader framing isn't even theirs: Chad Fowler — a veteran engineer and investor — calls it a Phoenix architecture, software designed to burn down and be reborn from a more durable artifact, and it gave Codeplain's founder the vocabulary he'd been missing. The instinct is exactly right. Review intent, not implementation. Regenerate, don't patch.

I also think the most useful thing about finding Plain is not that I can argue with it. It's that it is here at all. A funded company, with customers, arriving independently at the conclusion I'd been treating as a sample-of-one peculiarity, is not a threat to the thesis. It is the thesis: when authoring the high-level artifact stops requiring an institution, you do not get one eccentric retiree. You get a market. Plain is a witness I didn't have when I wrote that the construction had gotten cheap, and a witness who came in a different door — a company with customers, not one more lone builder — is worth more than one who came in mine.

So when I say I'm skeptical, I'm skeptical of one thing, and it is downstream of one choice. Everything else, we share.

What licenses you to throw the code away

Throwing the implementation away is the easy part. The hard part is what gives you the right to — what guarantees the regenerated thing is still correct, so that "I deleted it and made another" is an engineering practice and not a prayer. And here history is unkind and clarifying: it has only ever issued two licenses.

One is a proof. SQL is the great example. You write a declarative query, the optimizer produces an execution plan, and you throw plans away without a second thought — nobody version-controls a query plan — because every plan the optimizer considers is a provably equivalent rewrite of your query under relational algebra. The plan is disposable because the transformation is correctness-preserving by construction. That license is real, total, and available only in a closed domain that has an algebra.

The other is a reference. When you cannot prove equivalence, you point at something concrete and define correct as matches this. That is the whole idea of conformance, and it's the only thing that generalizes to open domains, because it doesn't ask for an algebra that isn't there.

The fourth-generation languages are the graveyard of everyone who wanted SQL's disposability without SQL's algebra and never supplied a reference in its place: declarative "trust me" in an open domain, backed by neither leg. They didn't fail because business logic is too hard. They failed because they removed the proof and put nothing in the empty socket.

The most successful thing I ever stood near, in the standards world, was watching the reference license get issued properly. Before HTML5, browsers were "compliant" against prose specifications that said what a valid document meant and fell silent exactly where the browsers actually diverged — error handling, the messy real input — so they interoperated worst precisely where the spec stopped talking. The WHATWG fixed it by writing the reference behavior into the spec itself: a parsing algorithm, step by step, defined for any byte stream including garbage, so that "conforms to the spec" and "matches the reference" became the same sentence. The executable half of standardization — the part that was always too expensive to build, the part that often never got built — finally got built, and the browsers converged.

My own project gets the second license, and I want to be careful about how, because I used to describe it lazily — as if I'd been handed the reference. I wasn't. The reference is not Rails, and it is not something I inherited. The reference is my application, and I write it. I write it quickly, in Rails' DSL, with all of Ruby's programmer happiness, because in a greenfield the scarce thing is finding out what I even mean, and Rails is the lowest-latency, most pleasant way I know to put a behavior in front of myself and look at it. Then I do the one act that mints an oracle out of a program: I run it, observe it, and validate that it does what I intended. That validated, running application is the reference — and from that point my oracle is a diff against it, fetched URL by fetched URL, byte for byte. I am not in the proof business; I can't prove my Rust equals my Ruby. I'm in the reference business, and the reference is a thing I authored and confirmed by eye, not a thing that was lying around.

A court that holds its own trials

Now look at where that leaves Plain, and be fair about it, because Plain is not careless — it has tests. It even calls them conformance tests, and it ships ***acceptance tests*** besides. But read the documentation and its :ConformanceTests: are, in its own words, "generated from the specifications." In the distinction I drew in Conformance vs Comprehension, that is a spec test wearing the name of a conformance test: it descends from the written authority, not from observed behavior. And the renderer that writes the code is a large language model — "probabilistic in nature," the whitepaper says, plainly and to its credit — while the tests that check the code are generated from the same prose by the same renderer. The court and the defendant were briefed by the same lawyer. There is no independent reference; the thing the regeneration answers to is the prose it was generated from.

Plain knows prose is treacherous. It spends an entire, genuinely good whitepaper on the problem, introducing a :Concept: notation to pin down the words that carry too much meaning, because — its own line — an LLM "has no basis for determining which interpretation is intended beyond statistical inference." I agree with every sentence of that whitepaper. I just think that, read straight, it is an argument against the pipeline it is defending. If probabilistic interpretation of prose is unreliable enough to need a special notation to fence it in, the deeper fix is not to annotate the prose more carefully. It is to stop interpreting it probabilistically at all.

Why my project looks the way it does

Which is the choice my project makes, and the reason it can make it is that I don't start from prose.

Rails is not a description of a program. It is a program — more precisely, an interpreter. Its routes, associations, and validations are structures the framework walks at request time, and an emitted target is what you get by specializing that interpreter to one specific application: the router stops dispatching over a generic table and becomes concrete handlers, the metaprogramming resolves into typed queries, the interpretive generality is compiled away. That's an old idea with a name — the first Futamura projection — and what's new is only that it's cheap now. The contrast with Plain is exact: a prose spec is a description you must re-interpret, by guess, every single time; a framework is an interpreter you can specialize, deterministically, once. And a deterministic specialization gives you something to stand on: run it a thousand times and you get the same bytes, which you can check against the reference once and trust thereafter. You cannot do either with the output of a model that may read the same paragraph two ways on two Tuesdays.

So the eccentric-looking choices line up, each one answering a place where I was skeptical of the other door. Author the behavior on a blank page in an executable framework-DSL rather than in prose — fast, because Rails is built for speed, in a language whose conventions were already carrying the types so intent is there to infer rather than annotate, and pleasant enough that I'll run the loop a hundred times to find what I mean. Lower with a deterministic compiler instead of a probabilistic renderer, because regeneration you can trust has to be byte-stable. And let the oracle be the running application I validated by hand, not a test apparatus spun from the same prose that wrote the code, so the court and the defendant have different briefs. None of those is cleverness. Each is a direct response to "what licenses throwing the code away," answered with a reference I author and check, because I had no algebra and refused to settle for a prayer.

The part I owe them

Here is what I owe Plain, and it took a wrong turn to see it. I almost wrote that I have the easy job — that my reference comes free because I work in brownfield, carrying an application someone already built and debugged, while Plain labors in a greenfield with nothing to point at. That is wrong, and the way it is wrong is the actual difference between us. I am in greenfield too. The key asset was never Rails; it is my application, and on a blank morning it doesn't exist yet either. We both start from nothing.

So the distinction was never brownfield versus greenfield — it is what you author onto the blank page, and whether the thing you author can validate itself. I author a running application, in a DSL fast and pleasant enough that I'll write it before I fully know what I want and iterate until I do, and I confirm it the only way that counts: by watching it run and checking it against what I meant. Plain authors a prose specification, which cannot run, and so cannot be watched. The same blank page, two media — and only one of them can become its own validated reference. That is my whole skepticism, stated fairly: not that Plain took the harder ground, but that it chose a medium that can never itself be the oracle, and so must keep a separate apparatus to stand in for one — and at the moment builds that apparatus out of the very prose it is meant to check.

The repair is not to abandon the medium; it is to stop asking the prose to validate itself. When your authoring medium can't be its own reference, you do what the WHATWG did — you legislate one: write the observable behavior down, separately, until conformance has something independent to mean. The tool for it is already in Plain's hands: those ***acceptance tests***, developer-authored, observable, independent of the generated prose. Every one of them is a small piece of legislated reference behavior, a recognition frozen into something that outlives the moment you recognized it. The friendly-stranger suggestion I'd leave on the table is to lean on those as the thing the regeneration answers to — to grow them into the trial court — and to stop letting spec-derived tests wear the word conformance, because the word is the one part of standardization you can't afford to spend loosely.

I've argued before that exploring a greenfield by producing a thing, watching it fail, and fixing the description rather than the output is a legitimate way to work now that an iteration costs minutes — it is the rehabilitated version of "bring me a rock," and it is how a great deal of real discovery has always happened. That loop is the heart of both our projects, and on it I think Plain is right and I'm a little envious of how directly it's aimed. The only law the loop has ever had is that the recognition behind each rejection must be banked into something that survives you looking away. When I validate a running app and keep it, that banking is automatic — the thing I looked at is the thing I keep. In prose you have to bank it on purpose, and the acceptance test is where you do. A prose edit is not.

And I owe them a correction to my own concession, because the obvious version is wrong. I started to say prose asks less of its author — no Ruby, no framework, just structured English. Plain's own user testing found the opposite: developers resist writing specs even as they're happy to read them. Writing the durable artifact is exactly the part people won't do — the same rock that sank spec-driven development every prior time. Plain's answer is honest and clever: plain-forge, where the agent drafts the spec from conversation and the human reviews it and checks the running software, "building relations with the spec" an increment at a time. But notice what that does. The promise was that the spec is the durable thing the human authors and maintains; once the agent authors it, the human's contribution is no longer the prose but the validating and the steering — judgments against running software that the agent transcribes. Which is precisely where I'd put the irreducible human work, and where Drucker and the change that transformed my own results put it. Automating the part nobody wanted to write quietly concedes that the spec was never where the authorship lived. The validation was. So the difference isn't that prose is easier to write than Ruby — it's that Rails is pleasant enough to write that the human stays the direct author of the durable artifact, no agent in the middle, while Plain found its medium unpleasant enough to author that it routed the human out. What prose keeps is reach: a non-programmer can read and steer it without a framework, and for much of the eventual audience that may matter more than anything I've said. That bet is real. But "maintain the spec, not the code" lands differently once the spec is something the agent writes and you approve — at which point we are both, again, doing the same job.

In The Scarce Thing I said the input about to become scarce is judgment about which behaviors are worth pinning down. Standing next to Plain, I'd add a second axis to that judgment, and I'm grateful to them for making me see it: not only which behaviors, but what your regeneration answers to — an executable thing you author and validate, or a separate reference you legislate around a description. Two doors, one room. We agree the code is ash and the durable thing sits above it. Where we differ is on what the durable thing is allowed to be — a description, or the running, validated behavior a description is only ever the last appeal to.

I am still a sample of one, with a strong prior and a single project, and I would rather find the cracks in this than not. Finding out that a stranger walked into the same room through a different door is the best way I know to do that — and the most useful thing about the third door is that it makes the shape of my own a great deal easier to see.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | The Scarce Thing2026-06-27T07:58:05.000Ztag:intertwingly.net,2004:3439

This week I'm at a retreat on the future of software development, and this is the perspective I'm bringing to explore, in one place. A few months of posts argues it strand by strand; here is the shape they make together. For a few months now, retired, I'm building a compiler I can't read against an oracle I could — and the through-line of everything since is what that arrangement implies once you notice that the cost of building it has collapsed, from an institution and years to a person and a few months. The asset relocates from the implementation to the oracle; rigor moves from comprehension to conformance; managing the agent inverts into managing by objective; and the abstraction ladder, stalled for a decade because each rung needed a funded team, starts climbing again. Which leaves one question for the room: when anyone can build the thing code answers to, do we still know which things are worth answering to? With links to where each piece is argued in full, and the sample-of-one caveat over all of it.

This week I'm in Switzerland, at a retreat on the future of software development, in a room full of people who have thought about that future longer and harder than I have. I haven't come to out-theorize them. I've come with a sample of one — a single project, built in retirement with an agent as co-author — and a few months' worth of posts arguing about what it might mean. This is the map of those arguments: the overall shape in one place, with each strand linked to where I make the case in full. If you only have room for the conclusion, it's at the bottom. Everything between here and there is how I got to it.

And I'm bringing them to explore, not to defend. It's an unconference — people propose what they most want to dig into and gather around it — so these are the questions I'm hoping to put on the board. I expect to be made welcome; it's that kind of room, and I'm a friendly enough stranger to it. Whether the ideas are made as welcome is the part I can't predict — and, honestly, the reason I'm going. A sample of one is exactly the kind of thing a room like this is built to stress-test, and I would rather find the cracks here than never find them at all.

Start with the observation the rest depends on, because if it's wrong, the rest is decoration. Over a few months I've been building Roundhouse, a compiler that reads an ordinary Rails application — untyped Ruby — and emits standalone, statically typed projects in Rust, Crystal, TypeScript, Go, and more, each compiling clean and passing its tests. I want to be precise about my role in that, because it's the whole point: I did not write the compiler. I can't write a compiler; I've never written one, and I can't read the Rust it generates. What I wrote was the thing the compiler has to satisfy — a fixture, a set of tests, a gate that fetches the same URL from Rails and from each target and checks that the responses are byte-for-byte identical. The agent wrote whatever passed it. The Oracle Is the Asset is the long version of what follows: when the agent holds the implementation depth you lack, the implementation stops being the thing you author. The thing you author is the oracle — the definition of what "correct" means — and the implementation is generated to satisfy it.

That project is one data point, and I've tried throughout to treat it as one. From a Sample of One is me holding it next to the findings of the retreat that preceded this one, looking for where my view and the room's diverge, and assuming the difference is a difference of regime — one retired person with an agent is not a team of twenty inside a company — until proven otherwise. Hold that humility over everything below.

The cost collapsed

Here is what makes the data point worth a post rather than a shrug. Whole-program type inference over a real framework, with a conformance oracle that mechanically decides whether an implementation is correct, is the kind of artifact that used to require an institution: a funded compiler team, a vendor, a research lab, or a standards body with member companies and a multi-year process. I spent years in that last world, and the executable conformance suite was always the expensive half of standardization — the part that often never got fully built. What changed is not that the agent writes code faster; everyone in this room has metabolized that already. What changed is that the cost of authoring the artifacts that used to require institutions has collapsed to a person and a few months. Conformance vs Comprehension is the full argument, and it's the one I'd most want this room to sit with: cheaper construction doesn't mean less software, it means we author orders of magnitude more of the things — oracles, compilers, domain standards — that were previously too expensive to build at all.

Rigor moved from comprehension to conformance

It changed how I work, not just what I can afford to build. The retreat's largest question was where engineering rigor goes once the agent writes the code. My answer is the uncomfortable one: for three months, on this project, conformance didn't augment my comprehension of the code — it replaced it. I never read the generated code, not once, and couldn't have judged it if I had. That sounds reckless until you see that the understanding didn't vanish, it moved. I understood the problem completely — which behaviors mattered, what correct output looked like byte for byte — and I authored the oracle, and the code is that oracle's output. Reading it to reassure myself would be checking the machine's work against the input I handed the machine. Conformance vs Comprehension draws the distinction I think our field keeps getting backwards — between the conformance test that does the overwhelming majority of the work and carries almost none of the cognitive load, and the written spec that is only ever the court of last appeal. We lavish our attention on the appellate court and starve the trial court that handles every actual case.

The working relationship inverts

If the agent holds the depth and you hold the problem, the management relationship inverts — and it turns out the inversion was described long ago. The Drucker Inversion is the structural version: Drucker noticed in 1959 that knowledge workers know more than their managers and have to be managed by objective rather than by method; the agent is that worker, and the principal directs by outcome because the outcome — the oracle — is the only place the principal's control and the agent's freedom meet. What Do You Recommend? is the same idea from the practitioner's end: the single embarrassingly small change that turned my own results from modest to transformative was to stop issuing choices and start asking for judgment, because on the specifics in front of us the agent usually knew more than I did. And because building the wrong thing got cheap, an exploratory style that used to be too expensive to indulge becomes rational — Bring Me a Rock is about how a management dysfunction inverts into a defensible method once iteration costs minutes instead of days and no one absorbs a cost they didn't choose. Each of those three is honest about the same unresolved seam: the agent contributes like a peer and has the stake of a tool, and I don't yet know how much of "peer" survives that.

The ladder started moving again

Step back from the one project and the motion looks older and bigger than any of it. For fifteen years the place where software gets defined has been climbing — language to framework, and then it kept going — and each rung was reached by building a tool, an analyzer or a compiler, that read the whole program and made that rung's ceremony disappear. The ladder stalled not because we ran out of ideas about where abstraction should go next, but because building those tools required institutions. The Rungs is the whole climb: the framework becomes a whole-app compiler (Rails was already typed — its conventions had been carrying a type system for twenty years), the compiler goes full-stack, the targets stop being exercises for the student, and finally — the rung this AI-native moment actually forces — the compiler turns around and answers questions about everything it made invisible. That last rung is real today, not a prediction: Live Types for Rails is the same inference that emits nine languages, pointed at an editor and an agent instead of a code generator, answering what is the type here, can this be nil, what won't survive ejection with no annotations and no running app. The payoffs underneath are real too — The Compilers Were Ready on why shape-stable output lets every existing compiler do its best work, and The Ruby JRuby Was Built to Run on the order of magnitude that buys. But the durable thing was never the compiler, or the speed. It was the oracle every rung answers to.

The scarce thing

Which is where all the strands meet, and where I'll leave it for the room. If the language is a substrate you don't think about, the framework's idioms are invisible, the types are inferred, the boundaries are compiled away, the targets are chosen for you, and the agent generates the implementation against a compiler that certifies it — then what is left for a person to author? The answer the whole climb converges on is: the definition of correct. The oracle. The statement of what counts as done, against which everything below is generated and replaced at will. That is the one input the agent still doesn't supply, because the one thing no layer beneath you can infer is what you were trying to build in the first place.

So the scarce input is about to change. When construction was scarce, we organized everything around who could build. When hundreds of people can each author a conformance oracle over a weekend — and I think that's exactly where this goes — construction stops being the bottleneck and judgment becomes it: judgment about which behaviors are even worth pinning down, which things are worth making an oracle of. I don't know how we get good at that, or who decides, or whether peer is the right word for the participant helping us do it. I've been honest elsewhere about how little one project can settle, and the numbers come without conclusions on purpose. But I'm fairly sure the question has stopped being who can build the thing code answers to, and become whether we still know which things are worth answering to. That's the conversation I came to Switzerland for.

I'll post an update when I'm back — once I've found out how much of this survives contact with a room that knows more than I do.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | What Do You Recommend?2026-06-27T07:57:05.000Ztag:intertwingly.net,2004:3438

I was asked to speak to my experience using AI to build software, and it surprised me — I've been at this less than a year, in a field where the words you hear most are slop and hallucination. That hasn't been my experience, and the reason comes down to one change I made early, almost by accident: I stopped issuing choices — do A, do B — and started asking a question instead, what do you recommend? That was the unlock. It is the same move I learned leading a team in the nineties, where a good lead's value is rarely being the most knowledgeable person in the room — it is drawing out whoever is. Peter Drucker named the shape in 1959: when the worker knows more than the manager, you manage by objective, not method. What I haven't resolved is the word peer — the agent contributes like one and has the stake of a tool, and I've been navigating that mismatch by borrowing the instincts that worked on the human version and hoping it doesn't bite.

I was asked to come speak to my experience using AI to build software. I want to be honest that this surprised me. I've been at this since August — less than a year — and the room I'm walking into has people who've thought about it longer and harder than I have. I'm not going to out-theorize them. What I have instead is a sample of one that happens to have worked, in a field where a lot of people report that it doesn't. The words you hear most are slop and hallucination. That hasn't been my experience, and the reason it hasn't comes down to one change I made early, almost by accident. This is the post about that change, because I think it's the most useful thing I can bring.

The ordinary bad start

I started the way most people start. I told it what to do. Do this. Now do that. Change this line. No, the other one. Imperative, step by step, me driving every turn.

The results were what a lot of people get from that approach: fine, not transformative. The productivity gain over just doing it myself was real but modest, and it came with a tax. The thing kept stopping to check with me. Every step, a confirmation. Shall I proceed? Is this what you meant? Do you want A or B? I was the bottleneck on a loop that paused for me constantly, and the work moved at roughly the speed of my attention. If that had stayed the experience, I'd have written the same skeptical posts everyone else writes.

Something I already knew

Years ago I led the team that built a software configuration management tool — version control, issue tracking, continuous integration, the kind of thing you'd assemble from off-the-shelf parts today but that we built from scratch. I wrote code, but that was not my main contribution. My main contribution was keeping the team moving.

People came to me with questions. And the thing I learned — the thing every decent team lead learns — is that the worst way to answer a question is to answer it. Hand someone the answer and you've solved today's problem and taught them to come back tomorrow. What worked was guiding: asking the question behind their question, pointing at where the answer lived, letting them arrive at it. Not because I was being pedagogical for its own sake, but because they were usually closer to the specifics than I was. They had the context loaded. My job wasn't to know more than them about their piece — it was to create the conditions where the person who knew the most could get to the answer.

I want to be precise about that, because it's the whole hinge: a good team lead's value is rarely being the most knowledgeable person in the room. It's drawing out whoever is.

The change

So I tried the same thing with Claude. The change was almost embarrassingly small. Most of the places where I'd been issuing a choice — do A — I started asking a question instead: what do you recommend?

That was it. That was the unlock. My productivity rose considerably, not incrementally, and the constant confirmation loop mostly dissolved, because I'd stopped asking it to wait for me to direct the method and started asking it to bring me its judgment about the method. I was managing it the way I'd managed the team — by objective, not by step — and it turned out that the same move worked for the same reason. On the subject matter directly in front of us, Claude frequently knew more than I did. Treating it as a peer, or honestly as someone more knowledgeable than me on the specifics, was what got out of its way.

The imperative style had failed for a reason I should have seen immediately, having lived it from the other side: I was dictating the method to a worker who understood the method better than I did. Of course that was slow and full of stops. I'd have been a bad team lead doing that to a person. I was being a bad team lead doing it to Claude.

Why it transfers

The instinct that made me useful leading people in the nineties is the instinct that made me productive with an agent in 2026, and it's not a coincidence — it's the same situation wearing different clothes. In both cases the participant closest to the problem knows more about it than I do in the moment. In both cases my leverage isn't supplying the answer; it's asking the question well, holding the objective steady, and letting the more-knowledgeable party do what they're better positioned to do. The thing I learned managing developers transferred whole, because the underlying shape — a leader whose value is conditions rather than answers, working with someone who holds the specifics — is identical.

Peter Drucker noticed in 1959 that knowledge workers know more about their work than their managers, and that this demands managing by objective rather than method. The agent relationship has exactly that shape. The four words that operationalize it are what do you recommend — and do this, do that is the opposite, managing by method, failing precisely the way Drucker said managing-by-method fails when the worker knows more than you.

This very piece is an instance of it. I didn't write this by dictating sentences to an assistant. I brought the experience, the half-formed claims, the context — and asked what it thought, pushed back where the read was wrong, corrected a framing it got backwards, and let the argument develop between us. The method the post describes is the method that produced the post. I don't know a cleaner demonstration than that.

The part I haven't resolved

I keep saying peer, and I'm not sure the word survives scrutiny — which is the thing I'd most like to talk about with people who've thought about it longer than I have.

A peer, normally, is someone with stake. The developers on my team could be right or wrong and it mattered to them; they owned their work, they'd live with the consequences, they had skin in it. That stake was part of what made "what do you think" a real question — I was deferring to someone who'd bear the result. Claude has no stake. It won't live with the consequences, doesn't own the outcome, isn't accountable in the way a colleague is. Its judgment is often genuinely better than mine on the specifics, and it is genuinely indifferent to whether I take it.

So what is it? It contributes like a peer and has the stake of a tool. "Peer" overclaims; "tool" badly undersells something that out-reasons me on the subject at hand and proposes things I didn't ask for that turn out to be right. The honest answer is that it's a new kind of participant that neither word fits, and I've been navigating it by borrowing the management instincts that worked on the human version and quietly hoping the mismatch doesn't bite me somewhere I haven't noticed.

That's as far as my sample of one takes me. The one change — ask what it recommends instead of telling it what to do — is the most useful thing I've found, and I'm fairly confident it generalizes, because it's just good management pointed at a new kind of worker. What I'm not confident about is what that worker actually is, and whether the instincts I'm importing will keep holding as the work gets higher-stakes than a retired developer's side project. If you've worked out how much of "peer" really applies to something with no stake — or found the place where treating it as one breaks — that's the conversation I came for.

Aquileo | Conformance vs Comprehension2026-06-27T07:56:05.000Ztag:intertwingly.net,2004:3437

The durable asset in software keeps relocating — from the code you inspect to the thing the code answers to. Red Hat sold conformance and certification, not the kernel; Ecma standardized C# as a behavior, not a source tree; and over a few months, retired, I built a compiler I can't read against an oracle I authored. From that sample of one, two claims for a room convinced the axis is expressive-versus-safe: it is really annotated-versus-inferred, and the cost of authoring the artifacts that used to require institutions has collapsed. The harder claim is what that did to me. For three months conformance didn't augment comprehension — it replaced it; I never read the generated code and couldn't have judged it if I had. The rigor didn't vanish, it moved: to the oracle the code answers to, where the conformance test does the overwhelming work and the spec is only the court of last appeal.

In 1991, a young student from Finland changed the world.

In the mid-1990s I lead a team working on software configuration management tools for IBM — loosely an on-prem, pre-internet GitHub, with version control, issue tracking, and continuous integration. Overly ambitious for its time; trivial by today's standards — in no small part because that same student from Finland came back in 2005 and turned version control itself into a primitive, with Git. Hold that thought; I'll come back to it.

Our target market was enterprise, so we supported all the major Unix systems of the era: Solaris, HP-UX, and of course IBM's own AIX. Our code was C, and every source file opened with a thicket of #ifdefs to paper over OS differences. I saw this new thing called Linux, and one weekend I ported our code to it. My management appreciated the initiative, and as luck would have it, a visiting executive was coming to town — she had me present what I'd done to him.

Suffice it to say, he was not impressed. He saw no future in open source, least of all for enterprise customers. I could end the anecdote here by calling him an arrogant fool, or by noting that the distribution I'd happened to choose was Red Hat. But neither is my point.

Given the information available at the time, his call was eminently defensible. Open source was — and remains — chaotic and unpredictable. Enterprises require governance.

What I want to suggest is that he wasn't wrong about open source. He was wrong about where to look. He was governing the artifact — the code, the thing you inspect, certify, and support — and the artifact was not where the durable enterprise value was going to live. A decade and a half later, IBM paid thirty-four billion dollars for Red Hat. Red Hat did not sell the kernel. It sold conformance, certification, and indemnification: a way to govern open source without inspecting every line of it. The asset had relocated, from the code to the thing the code answered to, and the old model couldn't see the new location because it was busy guarding the old one.

I've spent a fair amount of my career standing near that relocation, on the side that builds the thing code answers to. Before I retired I spent years in the standards world — co-chairing HTML at the W3C, secretary for Atom at the IETF, ssitting in TC39 and convening the C# and CLI working groups at Ecma. That last one matters most for what I want to say today, because standardizing a language and a runtime is not standardizing an artifact. It's standardizing a behavior: the precise set of things an implementation must reproduce to count as conformant. ECMA-334 and 335 existed so that Mono could be a legitimate implementation of C# and the CLI without a line of Microsoft's source. The spec was the neutral oracle. The implementations were downstream of it.

So conformance-by-testing is the water I swim in. When I notice something, that's usually the shape I notice it in. I'm telling you this not to claim authority over the conclusions — a man with one project and a strong prior should not be claiming authority over anything — but so you understand why a particular thing jumped out at me, and why it might be worth a second look even though my sample size is one.

The project

Here is the thing that jumped out. Over a few months, retired, working with Claude as a co-author, I built a compiler called Roundhouse. It reads a Rails application — untyped Ruby — and emits standalone projects in several statically typed targets: Rust, Crystal, TypeScript, Go, Python, Elixir, plus a Ruby round-trip. The emitted projects compile clean and pass their tests. The way I know they're correct is a conformance oracle: the same URL fetched from Rails and from each target produces byte-identical responses, checked three ways — emitted unit tests against fixed expected values, a differential compare gate against live Rails, and end-to-end browser tests for the dynamic behavior a static diff can't reach.

I want to be careful here, because there are two different claims tangled in that paragraph, and they land very differently in a room like this. Let me pull them apart.

The first claim: the axis might be wrong

The retreat's findings land, in the section on languages for agents, on a sensible-sounding conclusion: languages that favor expressiveness over safety make both agent generation and human review harder. The room converged on strong static typing as a guardrail for agent output — make incorrect code unrepresentable.

Roundhouse is a small piece of evidence that the axis might be drawn in the wrong place. It performs whole-program type inference over an untyped Rails application and emits into typed targets that hold the safety property — without anyone writing a type annotation. The safety is real; the annotation tax is zero.

Now, I don't want to oversell this, because the typed-language people in this room will rightly hand me a counterexample if I do. Type inference dissolving the annotation tax is not news — the ML tradition has shown it for forty years. The honest version of my claim is narrower and, I think, more defensible: Rails was already typed. has_many :comments is a type declaration; the conventions of the framework are implicit type information that was simply never written down. Whole-program inference can recover it. So the axis I'd put back to the room is not "expressive versus safe." It's "annotated versus inferred" — and a related question the project raises by existing: does the artifact the agent generates into have to be the same artifact a human edits? In Roundhouse it isn't. I edit the dense, intent-describing source; the compiler emits the verbose, mechanism-describing target. Neither audience pays the other's verbosity cost.

That's the first claim, and it's a genuine, falsifiable disagreement with one of the retreat's conclusions. But it's not the one I think matters most today.

The second claim: the cost collapsed

The claim that matters is about what it cost to build that.

Whole-program type inference over a real framework, with a multi-target conformance oracle, is the kind of thing that used to require a funded compiler team, or a vendor, or a research lab, or — in the case I know best — a standards body with multiple member companies and a multi-year process. Standardizing the CLI so that Mono and Microsoft answered to the same spec took a working group and years. The conformance oracle in particular — the executable part that mechanically decides whether an implementation is correct — was always the expensive half of standardization, the part that often never got fully built. Test262 for JavaScript came long after the prose spec, and it was a real lift.

What Roundhouse demonstrates, as a sample of one, is that the expensive half has gotten cheap. One retired person and an agent, in months, produced the conformance oracle and the multi-target compiler that answers to it. That is the observation I'd ask this room to sit with — not "AI writes code faster," which everyone here has already metabolized, but "AI has collapsed the cost of authoring the artifacts that used to require institutions."

And the moment you say a thing got cheaper to produce, this room will reach for the same idea I did: Jevons. When you make a resource cheaper to use, total consumption goes up, not down — the more efficient steam engine didn't reduce coal use, it made coal economical for more things, so we burned more of it. Apply that here and the comforting half of the conclusion writes itself. Cheaper software production does not mean less software or fewer engineers; it means vastly more software, and more demand for the judgment that directs it. We will not author fewer oracles now that authoring one is cheap. We will author orders of magnitude more. That is the demand-side engine underneath everything I'm about to say — it's why the answer to "one person built this" is not "how quaint" but "then there will be a great many of them."

And notice how that closes the loop with the standards lens. The durable thing in Roundhouse is not the compiler — I couldn't write a compiler, and I can't read the Rust codegen it produces. The durable thing is the oracle: the reference behavior every target is tested against, the thing I actually authored. When the next model writes a better emitter, it still has to pass my oracle. That is exactly the relationship a conformance suite has to its implementations, and exactly the relationship the IBM executive couldn't see between Red Hat's certification and the Linux kernel. The asset is the spec the implementations answer to. What changed between the 1990's and 2026 is not that this became true — it was always true. What changed is that authoring the asset stopped requiring an institution.

Where the rigor went: conformance, not comprehension

The retreat's largest question — the one it says surfaced in nearly every session — was where the engineering rigor goes once the agent writes the code. The room gave five answers: upstream into specifications, into test suites, into type systems, into risk tiering, and into "continuous comprehension." I'll sign two of those without hesitation, set one aside, and spend my time on the one I think is drawn backwards — because the project I built lands squarely on it.

Specs and tests first, because there I'm just agreeing — and in this room, agreeing with people who made the case for test-first development long before there was an agent on the other end of it. The report's sharpest single line is about TDD: tests written before the code stop "a particular mental error where the agent writes a test that verifies the broken behavior." That is exactly right. It's worth saying that this conviction is not universally shared outside these walls — Linus, the same student from Finland this talk keeps circling back to, is famously skeptical of test-first development, and on most days I'd rather stand with him than against him. On this one I'm with the room and against him. Every iteration of Roundhouse was driven test-first, and the discipline did precisely what many of you have argued for years it would: it made it impossible for the agent to declare victory by quietly lowering the bar. The test is the bar, and the agent doesn't get to move it.

But notice which tests, because this is the distinction I think the room undervalues, and it's the whole game. There are two kinds, and we lump them together. A spec test asserts a behavior because the specification says so — it descends from the written authority. A conformance test asserts a behavior because a reference implementation, or the real consumers downstream of it, actually depend on it — it descends from observed behavior. Test262 is a spec suite: it exists because ECMA-262 says so. The Roundhouse oracle is a conformance suite: it exists because Rails does a thing and a correct target must do the same thing, byte for byte. Most of the rigor in real systems lives in the second kind. Most of the prestige attaches to the first.

Let me make the case with the smallest project I can. Roundhouse needed a real Rails app, and the one I most wanted was Mastodon, and Mastodon's views are written in HAML — so "support Mastodon" quietly became "implement HAML." Here is the entire method. I surveyed the HAML features Mastodon actually uses — not the language, the subset Mastodon leans on. I implemented those. I tested them against Mastodon's own source as the corpus. The tests immediately surfaced the gaps — constructs I'd gotten subtly wrong or hadn't covered — and each gap became a small, fast iteration: failing case, fix, green, next.

And here is the part I want this room to sit with, because it is a direct counter to one of your five answers — and, I suspect, to something a good number of you hold dearer than any of the five. The report's continuous-comprehension section quotes someone saying paired programming "solves all of this" — that if it's important to understand the system you should "do it all the time," not in little phases. That is not a fringe view in this room; for many of you, pairing and continuously shared understanding are close to founding commitments, earned honestly over long careers. So let me be exact about where I'm parting company, because it turns on a single word. The comfortable version of my claim is that conformance augments comprehension — that the tests let me get away with understanding the code a little less. That is not what has been happening. For three months, across the whole project, conformance has replaced comprehension. I did not build a thin mental model of the HAML lowering and lean on the suite for the rest; I built none. I never read the generated code, not once, and could not have judged it if I had, because I don't write compilers. There was no architecture I was keeping current with, no system I held in my head, no session in which understanding changed hands. The standing model of what the code is doing — the very thing that section wants to preserve — was never in the loop.

That sounds reckless only until you see that the understanding didn't vanish; it moved. What I understood completely was the problem — which HAML features Mastodon leans on, what a fair subset is, what correct output looks like byte for byte. What I authored was the oracle: the corpus, the expected values, the compare gate. The implementation is that oracle's output — the agent writes code to satisfy the spec I wrote — so reading that code to reassure myself it's right would amount to checking machine output against the input I handed the machine. My control and the agent's freedom meet at the oracle and nowhere else. The day this stopped being theory for me was the day I noticed I find out whether a thing works the moment I finish authoring the test that defines "works," not the moment I run it: a benchmark number I'd aimed the whole project at, I executed for the first time minutes before I wrote it up, and it did exactly what I expected — because the oracle had already checked it, on every layer that mattered, and my own run was a formality. That is what replacement feels like from the inside. First-person confirmation becomes redundant by construction.

It is also why I'm unmoved by the decision-fatigue worry the retreat raises elsewhere — agents producing work faster than humans can say yes to it. I said yes to almost nothing in the HAML work. There were no judgment calls to fatigue me, because the corpus had already decided every case I cared about: the output matched Mastodon's, or it didn't. Decision fatigue is what you get when a human is the oracle. Move the oracle into the test suite and the fatigue goes with it.

So conformance tests are undervalued — that's the affirmative claim. But I owe you the boundary, because a conformance suite has a real failure mode and pretending otherwise would be cheating. It encodes whatever the reference implementation happens to do — its bugs, its accidents, and nothing about the inputs you never thought to try. It tells you that you match the oracle on the cases you tested; it cannot tell you the oracle was right. That is exactly where the written spec earns its keep. When a conformance test is silent, or when conformance and spec disagree, the spec wins — not because it's prestigious, but because it's the only thing that adjudicates the cases the reference implementation left undefined. The honest hierarchy: conformance tests do the overwhelming majority of the work and carry almost none of the cognitive load; the spec is the court of last appeal, reached for exactly when they run out. The mistake the industry keeps making — and the one I think the room half-makes when it reaches for type systems and comprehension as the guardrails — is to lavish its attention on the appellate court while starving the trial court that handles every actual case.

What life is like in 2031

Let me come back to where I started.

In 1991, a young student from Finland changed the world. The scarce thing, in 1991, was not really the ability to write a kernel — plenty of people could write a kernel. The scarce thing was the will to do it in the open, the timing, and a world willing to converge on the one who did. The construction was hard, but the construction was never the whole story.

And here's the thought I asked you to hold. He did it twice. In 1991 he gave us the kernel; in 2005 he gave us Git, and in doing so he took the entire product category I'd spent those years at IBM building — version control as a thing you bought — and collapsed it into a primitive you assume. One person dissolved a commercial category into infrastructure. That is the pre-AI proof of the thing I'm claiming. It has always been possible for a single person to have that kind of impact. What was scarce was being that person.

By 2031, the construction will be cheap. Not just writing code — building the things that used to take institutions. Conformance oracles. Multi-target compilers. Domain standards. Whole-program analyses that used to be research. If one retired hobbyist and an agent can build, in a few months, the kind of artifact that took Ecma a working group and years, then by 2031 there will not be one student capable of that kind of impact. There will be hundreds.

So the question I'd leave you with is not "isn't that exciting" — though it is. It's that the scarce input is about to change. When construction was scarce, we organized everything around who could build. When hundreds can build, the scarce thing won't be construction — it will be judgment about what is even worth pinning down, which behaviors are worth making an oracle of. That is the one input the agent still doesn't supply. In 1991 the world had one Linus and rallied to the kernel he chose to build. In 2031 it will have hundreds, each able to author a conformance oracle over a weekend — and the open question, the one I've spent my career on one side of and now find myself on the other, is no longer who can build the thing code answers to. It's whether we still know which things are worth answering to.

I don't know the answer. But I know which room I'd want to be asking it in.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | The Rungs2026-06-27T07:55:05.000Ztag:intertwingly.net,2004:3436

For fifteen years the place where software gets defined has been rising — nobody chooses Ruby, they choose Rails — and that migration didn't stop. This walks the ladder one rung at a time: the framework becomes a whole-app compiler, the compiler goes full-stack, the targets stop being exercises left for the student, and finally the compiler turns around and answers questions about everything it made invisible. The motion is old; what's new is the engine. Building the tool that reaches each rung used to require an institution — a funded team, a standards body, years — and now requires a person and an afternoon. With the graveyard kept in view, because a ladder of rising abstraction has one at its base, and the only discipline that separates this from it is telling an artifact boundary from an essential one.

For fifteen years the answer to "what are you building it in" has quietly stopped being a language and started being a framework. Nobody chooses Ruby; they choose Rails. Not Elixir but Phoenix, not JavaScript but React or Next. The language became the substrate you stand on and mostly stop thinking about, and the framework became the thing you actually decide, hire for, argue about, and identify with. That migration — attention moving up, from language to framework — is old news. What I want to do here is notice that it didn't stop, that it has been climbing a ladder one rung at a time, and that the rung we're stepping onto now is the same motion it always was. I don't know how far the ladder goes. But I think I can see what kind of ladder it is, and that turns out to be enough to say something useful.

The motion has a shape, and the shape is the point: at each rung, something that used to be hand-authored ceremony at the boundary below becomes inferred, absorbed, and invisible. You once wrote loops; the framework's idioms made the loop invisible. That's the whole story, repeated at rising altitude.

There's a second thing to notice, and it's the one that makes this worth saying now rather than at any point in the last decade. Every rung above the first is reached by building a tool — an analyzer, a compiler, a multi-target emitter — that reads the whole program and makes that rung's ceremony disappear. And until very recently, building that tool was an institutional act: a funded team, a standards body, a vendor, years. That cost is why the ladder stalled where it did. We didn't run out of ideas about where abstraction should go next; we ran out of organizations willing to build the compilers it would take to get there. What changed — the actual inflection the rest of this is circling — is that the tool which makes each rung reachable stopped requiring an institution and started requiring a person and an afternoon. The ladder isn't new. The fact that almost anyone can now build the rung they're standing under is what's new. Watch it climb, and watch what each step cost to reach.

Rung one: the framework replaces the language

This rung is behind us, so I'll be brief, but it's worth standing on for a moment because it sets the direction of travel. The center of gravity moved from language to framework, and once it did, the framework started carrying things the language used to. Rails doesn't just give you idioms; it encodes decisions — naming, structure, the shape of a request — that a language leaves to you. The framework became where the meaning lives. Everything above depends on that having already happened, because you can only compile a framework if the framework is the thing that knows what the program means. Rails was already typed, it turned out, precisely because Rails — not Ruby — had been carrying a type system in its conventions for twenty years. That's rung one paying off a decade later: the framework accumulated the semantics, and the semantics were waiting to be read.

Rung two: the framework wants to become a compiler

Tom Dale called this in 2017 — frameworks were turning from runtime libraries into optimizing compilers, and the bytes shipped to the browser would bear less and less resemblance to the source. He was right, and you can see how right on the JavaScript side: Svelte is a compiler wearing a framework's clothes, React's server components are a compile-time split, the build step quietly ate responsibilities the runtime used to own. Dale's advice to anyone who wanted to matter was "learn how compilers work."

Here is where I'd extend him, and where the extension is partly my bet rather than settled fact. Dale's compilers compressed the implementation — same language in, optimized same language out, for the machine's benefit. The rung I think we're actually on is wider: the framework reads the whole application as one analyzable artifact and emits from a typed intermediate representation, and what gets compiled isn't just the templates but the entire program's structure — its routes, its associations, its data dependencies. The difference between minifying a view and typing an entire Rails app by whole-program inference is the difference between a tool that compresses and a tool that understands. The industry has the first half. The second half is what I've been building as a sample of one, and I won't pretend it's proven at scale. But the direction is Dale's, and the direction is right: the framework wants to be a compiler, and it wants to be a compiler over the whole app, not just the parts that were already easy.

One thing changed since 2017 that matters more than any of this. Dale said "learn how compilers work" because writing one was a serious, scarce undertaking — the province of specialists and funded teams. That stopped being true. The compiler I lean on most, I could not have written by hand; I authored the thing it had to satisfy and let an agent build the rest. So Dale's imperative inverts. It's no longer "learn to write compilers." It's "compilers are cheap to commission now, so the scarce skill moves to deciding what to compile, from what, to what." That inversion is what makes the higher rungs reachable at all. A motion that required an institution at every step would have stalled. This one doesn't.

And it isn't only me, which is the part that should make you take it seriously rather than treat it as one retiree's anomaly. While I was teaching a Rails app to compile itself into nine languages, someone else was writing — from scratch, in dependency-free C, no LLVM and no Apple runtime — a complete Swift compiler, SwiftUI runtime, Foundation bridge, and Metal shader compiler that runs entirely in a browser tab, retargeting Apple's platform to WebAssembly and canvas. (The project, MiniSwift, reports seventy-odd thousand lines of C and several hundred passing tests; I'm taking those figures from its author, the way I'd want you to take mine.) Different language, different stack, no knowledge of my work nor I of theirs until after — and the same move: a single person building the kind of whole-language compiler that used to require a vendor, and using it to turn a platform boundary into a build target. Two doors, one room. When the cost of building the tool that makes a rung reachable falls far enough, you don't get one person climbing. You get strangers arriving at the same rung from opposite sides, having never coordinated. That's what an inflection looks like from the ground.

Rung three: the framework becomes full-stack

This rung is not speculative either; it's where the mainstream is standing right now. Next, Remix, SvelteKit, Phoenix LiveView — the defining move of the last several years has been frameworks swallowing both sides of the client/server boundary into a single programming model. You write something that looks like one program, and the framework arranges for part of it to run there and part of it to run here. The boundary didn't go away, but the framework took over the job of negotiating it.

I'm aligned with the industry on this rung and differ only on method, which is worth being precise about because the method is where the next rung hides. The mainstream unifies the boundary by coordination — LiveView holds a stateful connection across it, server components coordinate a serialization protocol over it. The boundary is still there; the framework is just very good at talking across it. The alternative, the one whole-program compilation opens, is to unify by elimination: if both sides are compiled from one source into one artifact, there is no wire to coordinate, because there is no gap. You can watch the difference most clearly where it has historically hurt the most — system tests. The reason a decade of Capybara-and-Selenium tests were slow, brittle, and full of false negatives is that they were asserting determinism across a boundary that is nondeterministic by construction: a browser, a network hop, JavaScript timing, a server. DHH concluded the concept had failed. I'd say the separation failed, and the tooling was merely where the cost showed up. Collapse the stack into one artifact running in one process and the boundary isn't crossed more reliably — it's gone, and the class of bug that lived on it is gone with it. The tests that DHH gave up on run in tens of milliseconds with zero flakiness, not because the runner got better but because the seam they were fighting no longer exists.

So rung three, told honestly: the industry is already here on the destination — full-stack is the consensus — and the only live question is whether you reach it by getting very good at the boundary or by removing it. Both work. One of them makes a whole category of problem stop existing.

Rung four: the targets stop being exercises for the student

Here is the rung we're stepping onto, and here is where I stop reporting and start guessing, so I'll mark it plainly. For most of its life, a Rails application has had exactly one place to run: a server. Everything else — the browser as a first-class offline client, the phone as a native app, the edge as a worker with a tight memory cap — has been an exercise left for the student, or worse, an explicit bridge you hand-author and maintain forever. React Native rebuilds your client in a foreign stack and asks you to keep two apps in sync. Hotwire Native wraps a webview and asks you to write, by hand, the bridge between the native shell and the web content — the most visible, most fragile, most ceremonial code in the whole arrangement.

The rung says: those stop being destinations you port to and become targets you compile to. Browser, mobile, edge, cloud — build flags, not projects. And the reason this isn't just "more output formats is nice" is that each surviving target earns its place by removing a specific boundary and returning a specific, measurable benefit. The browser target isn't a curiosity; it's an offline-first deployment Rails couldn't otherwise reach. The mobile target isn't a checkbox; it's on-device native code with no bridge to maintain. The edge target fits a memory budget no Rails process fits. And running underneath several of them, the emitted code turns out to be the monomorphic, resolved-shape program a JIT was always built for — so the same compilation that buys you reach also buys you speed, an order of magnitude of it, as a side effect of having stripped the interpretive generality away.

This is also where I'm contemplating pruning, and that impulse is the tell that the rung is real rather than a demo. Exploring a wide spread of targets was how I proved the architecture was target-agnostic. Keeping all of them would be how I proved I'd lost the plot. So the spread collapses by principle: the Ruby family survives because it's nearly free — same language, marginal emit cost, and it buys the runtime and deployment spread. TypeScript survives because it's the universal client substrate that every browser, edge, and test tier rides on. Kotlin and Swift survive because they are the only way to reach on-device mobile as first-class native code rather than a wrapped webview. Free, universal, uniquely-enabling — three different reasons, each load-bearing. The targets that clear none of those bars did their job by demonstrating target-independence, and can retire.

What I suspect happens next is that the target goes invisible the way Erlang went invisible behind Elixir. Elixir didn't hide the BEAM by wrapping it; it became a better surface over the same substrate, and you reach down for Erlang only when you specifically mean to. If the same holds here, you won't choose Swift. You'll say you're building for iPhone, and the compiler will choose Swift, and the choice will be as invisible as the type annotations nobody writes anymore. And the bridge — the hand-authored, painfully visible bridge that Hotwire Native makes you maintain — becomes inferable for exactly the same reason the types did: the compiler that reads the whole app already knows which interactions are client and which are server. The information the bridge encodes by hand is information the analysis already holds. Make it invisible.

I want to be honest that this last move is the one I've made twice and not yet a third time. Types went invisible; I can show you that. Targets went invisible; I can show you that too. The bridge going invisible is a prediction — but it's the third instance of a pattern that held the first two times, and the pattern is specific: take something hand-authored at a boundary, recover it by whole-program inference, and let it disappear into the layer below. I believe the bridge because I've watched the same mechanism eat types and targets. That's not proof. It's the kind of confidence you're allowed to have when you're standing on a rung you climbed to.

Rung five: the compiler answers questions

The four rungs so far are a story about humans writing less. For fifteen years that was the whole benefit — invisible ceremony is ceremony you don't have to hold in your head. But there's a cost to rising abstraction that the human story politely ignores: every rung makes the system harder to reason about from outside, because more of it is implicit. The newcomer — the new hire, the contributor, and now the model — faces a program where the types aren't written, the bridge isn't written, the target isn't chosen in any one place. The information is all there, recovered by the compiler, but it was recovered in order to emit, and then it evaporated. Ask the running program what type comment is and there's no one to ask.

This is the rung the present moment forces, because the entity now doing much of the authoring is an agent, and an agent cannot read your mind about implicit structure any better than a new hire can — worse, it will confidently guess. Every Rails tool that tries to help it today either boots the whole application and reflects at runtime, or gives up and applies heuristics. There has been no way to ask a Rails codebase a sound, static question — what is the type here, can this be nil, where is this used, will this construct even lower to the target — and get an answer that's correct where it fires and honest where it can't.

But the compiler from rungs two through four already computes exactly those answers; it just throws them away after emitting. Keep them. Expose the whole-program analysis not only as a front-end that emits code but as a backend that answers queries — over a language server for the human's editor, over a tool protocol for the agent. The same inference that made the framework a compiler makes the framework legible, on demand, to whatever is building on it. The agent stops hallucinating the type and calls for it. The guardrail that an annotation used to provide — and that inference made invisible — becomes available again the instant something needs to check its work, except now nobody had to write it.

That's the rung the AI-native moment actually needs, and it's the one the framework-as-compiler story doesn't reach on its own. Rising abstraction spent fifteen years making the implicit invisible. The agent needs a way to make it explicit again on request — not by writing the ceremony back down, but by asking the compiler that already knows. Invisible to author, visible to interrogate. The compiler is how you get both.

What the ladder is actually made of

Step back and the rungs are one motion seen again and again: the place where software gets defined keeps rising, and at each level, ceremony that was hand-authored at the boundary below gets recovered by analysis and made invisible. Language to framework. Framework to whole-app compiler. Compiler to full-stack. Full-stack to all-targets. And then, when the implicit becomes too much to reason about, the compiler turns around and answers questions about what it made invisible. At every step, a category of explicit work — idioms, types, boundary coordination, target selection, bridges — stops being something you write and starts being something the layer beneath you infers. That's the ladder. It's not a prediction about frameworks; it's a fifteen-year-old habit of abstraction, still climbing, and the rungs I'm calling speculative are just the ones my feet haven't fully settled on.

But a ladder of rising abstraction has a graveyard at its base, and intellectual honesty means looking at it. The history of our field is full of boundaries that were declared about to dissolve and didn't. Distributed objects promised the network boundary would vanish and it didn't, because the network is a physical fact — latency and partial failure are real, and no abstraction smooth enough to hide them was ever fast enough to use. The fourth-generation languages promised the language boundary would melt into declarative specification, and mostly it didn't. So "boundaries dissolve" is not a law, and anyone selling it as one is selling something.

The discipline — the only thing that separates this from the graveyard — is telling an artifact boundary from an essential one. Some boundaries are accidents of tooling that couldn't see the whole program: the front-end and back-end of a small app are the same program, artificially split because the framework couldn't read both halves at once. Those dissolve, and the rungs above are the dissolving. But some boundaries are essential complexity wearing a code-boundary's clothes, and those do not dissolve — they were only hidden. Collapse the client/server code boundary and you do not abolish latency, partial failure, offline conflict resolution, or the deploy skew between a phone app and the server it left behind weeks ago. You surface them. The honest version of rung four is not "the boundaries go away." It's "removing the artificial boundary exposes the real ones that were hiding behind it, and those still need policy, judgment, and explicit decision." I know this because every place I've removed a code boundary, the genuinely hard problems — who's authorized to call this, which version of the contract is the phone running, how do two offline edits reconcile — are exactly what poke through afterward. The compiler made the easy boundary vanish and handed me the hard one in better focus. That's not failure. That's the abstraction doing its actual job: not eliminating the difficulty, but moving it to where it's real and refusing to let ceremony hide it.

So I don't know how far the ladder goes, and I'm suspicious of anyone who claims to. But the AI-native moment lets me ask a sharper question than "how far," which is: who is the invisibility for now? For fifteen years the answer was the human — less to write, less to carry. But if an agent is doing much of the authoring, the calculus changes, and it changes in a way I find clarifying rather than alarming. Ceremony that's inferred instead of written is ceremony the agent cannot get wrong: it can't mistype what it never typed, can't desync a bridge it never wrote, can't misconfigure a target it never chose. Every rung that makes something invisible to the author also removes a thing the agent could have botched — and rung five hands back the ability to check the rest. That is not a small reframing of safety. The way you keep an agent honest is not to review every line it writes; it's to give it a layer that's correct by construction beneath it and a compiler that will answer, soundly, when something needs verifying.

Follow that far enough and the ladder points somewhere specific. If the language is a substrate you don't think about, and the framework's idioms are invisible, and the types are inferred, and the boundaries are compiled away, and the targets are chosen for you, and the agent generates the implementation against a compiler that certifies it — then what is left for a person to actually author? The answer the whole climb has been converging on is: the definition of correct. The spec. The conformance gate. The oracle that says this output is right and that one isn't, against which everything below — generated, inferred, compiled — is measured and replaced at will. That's the top of this ladder as far as I can see it: the human climbs until the only thing still hand-authored is the statement of what counts as done, and the entire stack beneath it becomes derivation. Not because we decided implementation didn't matter, but because, rung by rung, we made it something the layer below could infer or generate — and the one thing no layer below can infer is what you were trying to build in the first place.

I can't prove that's where it ends; it's the view from a rung, not a summit. What I'll claim is narrower and, I hope, more durable: the motion is old, it is still moving, it climbs by making boundary-ceremony invisible through whole-program inference, and it has just acquired an engine. The thing that makes each rung reachable used to require an institution and now requires a person and an afternoon — and the thing being authored, as you climb, keeps shrinking toward the single irreducible act of saying what correct means. That's what the inflection is, underneath the noise. Not that the machine writes the code. That the cost of building the tools that make code invisible fell to nothing, the ladder started moving again after years of being stuck, and the part left for us turns out to be the part that was always the point.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.

]]>
Aquileo | Bring Me a Rock2026-06-27T07:54:05.000Ztag:intertwingly.net,2004:3435

"Bring me a rock" is a slur — it names a manager who substitutes serial rejection for the work of saying what they want, and makes you pay for their unfinished thinking one rock at a time. I want to argue that under conditions that now exist, the dysfunction inverts into a defensible way to work: not because the manager got better, but because the three things that made it a slur — imposed cost, latency, and exploited patience — quietly go away once it is peers exploring together with a tireless agent instead of a boss imposing on a subordinate. What doesn't go away is harder. Cheap iteration removes the one signal, cost, that used to tell real discernment from expensive dithering, and the peer framing means the recognition behind each rejection has to be not just real but shared.

There's a management story everyone in software has either lived or watched. A manager asks for something — a design, a plan, a logo, a rock — without saying what would make it right. You bring one back. No, not that one. You bring another. No. You iterate blindly toward a target that exists only in their head and is revealed only through rejection. The phrase for it is "bring me a rock," and it is a slur. It names a manager who substitutes serial rejection for the work of saying what they want, and makes you pay for their unfinished thinking one rock at a time.

I want to argue something uncomfortable: under conditions that now exist, "bring me a rock" stops being a slur and becomes a defensible way to work. Not because the manager got better. Because three of the things that made it a slur have quietly gone away.

Why it was a slur

It's worth being precise about why it was bad, because the reasons turn out to be separable, and only some of them survive.

It was bad because it externalized the cost of one person's indecision onto another person's time. Every rejected rock was a day of someone's life spent on a target they were forbidden to see. That's the moral core — not that iteration happened, but that the iterator paid for it and the requester didn't.

It was bad because it scaled horribly. In a real organization the loop compounds: you misunderstand, you wait in a queue, you hand off, the manager is in meetings for three days before rejecting. The round-trip is the killer, and it's why "bring me a rock" is poison on a team of twenty.

And it was bad because it spent a resource that wasn't the manager's to spend: your patience. "Give me criteria or I stop" is a legitimate boundary. The manager who leaned on "bring me a rock" was exploiting the fact that you couldn't, in practice, refuse the eleventh iteration.

Three failures: imposed cost, latency, exploited patience. Hold them separately, because the interesting thing is what happens to each one.

What changed

The reflex is to say this gets fixed by shrinking to a team of one — collapse the requester and the iterator into the same person and there's no one to impose on. But that's not the interesting case, and it's not the one I'm in. The interesting case is a team of peers — think co-founders who've hired a development team, except the development team is an LLM — and what fixes the three failures there isn't that the team is small. It's that nobody in the loop can run the pattern downhill.

Because that's what the original slur actually was: "bring me a rock" ran downhill, from someone with the power to impose to someone with the obligation to absorb. Flatten that, and watch the three failures go out one at a time — not because the team shrank, but because the asymmetry is gone.

The imposed cost is gone, because there's no one to impose it on. Peers exploring together aren't externalizing one person's indecision onto a subordinate; they're sharing the exploration as equals, and the agent doing the building isn't paying in time and dignity at all. There is no one being demeaned by the eleventh "no."

The latency is gone, because peers in a tight loop with an agent aren't handing work across an org chart. No queue, no three-day wait for the meeting where the rock gets rejected. The round-trip that made the pattern poison at scale is measured in minutes.

And the exploited patience is gone, because no one's patience is being spent against their will. The human peers aren't drawing down a subordinate's goodwill, because there's no subordinate. The agent doesn't tire, doesn't resent the rework, has no boundary you're crossing by asking for the twelfth rock. The resource the old manager was quietly stealing simply isn't on the table.

Strip out imposed cost, latency, and exploited patience, and what's left of "bring me a rock" is just this: a group of equals refining toward a target they're discovering by elimination. And that, stated without the baggage, is not a pathology. It's how a great deal of exploratory work has always been done. The photographer shoots a thousand frames for one. The writer drafts to find out what they think. And — closest to the case I mean — a startup pivots, and pivots again, rejecting whole directions not because the founders are too lazy to specify but because the target genuinely isn't knowable until you've built toward it and watched it fail. Startups change course constantly and mostly fail anyway. The serial rejection isn't the dysfunction; it's the method the search space demands. Seeing several directions fail — and several perspectives on why, the agent's included — is how the real target gets found, if it gets found at all.

What was actually wrong with "bring me a rock" was never the iterating. It was running an exploratory method downhill through an expensive human medium, where the cost of each rejected rock landed on someone who hadn't chosen to pay it. Among peers, with a tireless builder, both halves of that change: the medium gets cheap, and no one's absorbing a cost they didn't choose. The method was always fine. The arrangement around it was the problem. The arrangement is what changed.

The part I can't cleanly resolve

Here's where I stop being sure, and where I'd rather hand you the problem than pretend I've solved it.

There are two very different things that look identical from the outside. One is a person with a real preference they can't yet articulate — they genuinely know it when they see it, and cheap iteration is how they find the words for what was already there. The other is a person with no preference at all, who uses serial rejection as a substitute for the thinking they never did. From across the table, both just say no, not that one.

When iteration was expensive, the cost told them apart. The person with no real preference couldn't afford to wander forever; the expense forced a decision, or forced them to admit they didn't have one. Cheap iteration removes that forcing function. You can now reject a thousand rocks for free and call it discernment. The infinitely patient agent will let you wander infinitely — and infinite wandering is not convergence, it just looks like it for a while.

So the honest version of my claim has a fork in it. Cheap iteration rehabilitates "bring me a rock" when there's a real oracle in your head being discovered. And it makes the pattern more dangerous when there isn't, because it removes the one signal — cost — that used to expose the absence. The thing that separates valid exploration from expensive dithering is no longer visible in the behavior. Both are just rejection, repeated, cheaply.

I have a partial answer from my own work, and I don't fully trust it. When I build this way, the implementation collapses to minutes but the decision still takes hours — sketching alternatives, probing whether a metaphor generalizes, working out what a choice means three steps downstream. The hours of deciding are, I think, the tell: that's the oracle being formed, not avoided. The cheap implementation is just the verification that the thinking was right. So maybe the test is whether the cost moved or vanished — whether you relocated the work to the decision or simply stopped doing it. But I can't be certain, from the inside, that my hours of deliberation are forming a real preference rather than elaborately disguising that I don't have one. The dithering and the discernment feel the same while you're in them.

And the peer framing, which is the one I actually believe, makes this harder rather than easier — because now the recognition has to be not just real but shared. A team of equals exploring by elimination has no built-in arbiter; flattening the hierarchy is exactly what removed the person who used to get to say "this one, we're done." So the fork doubles. It's no longer only "is the recognition behind this rejection real, or is it avoidance." It's also "is it shared, or are we each rejecting rocks for incompatible reasons and calling the collision iteration." Startups that pivot well seem to have some common sense of what would count as right even before they can name it. Startups that fail often fail because the founders never had that, and cheap iteration let them not notice for a year. The thing that used to force the question — the expense of building the wrong rock — is the thing that went away.

The agent sits inside this question in a way I haven't worked out. It's a participant whose judgment is real in some directions — it'll catch a constraint the humans missed, propose a rock nobody asked for that turns out to be the right one — and absent in others, because it has no stake in what the thing is ultimately for. So when I say the perspectives include the agent's, I mean something I can't yet make precise: it's a peer in contribution and not, obviously, in stake, and I don't know how much of "peer" survives that. I'm leaving that one open on purpose.

That's as far as I've gotten. A dysfunction inverts into a method when you take it out from under the hierarchy that made it run downhill — when peers explore together with a tireless builder and no one absorbs a cost they didn't choose. But the rehabilitation holds only while the recognition behind the rejection is real and shared, and I don't have a clean way to check either from the inside, because the cost that used to check both is exactly what went away. If you've found a way to tell — real recognition from cheap avoidance, shared conviction from collided preferences, or how much of a peer an agent without stake actually is — I'd genuinely like to know it. That's the part I came here without.