Skip to content

Plugins

Two plugins that need incompatible versions of the same gem cannot load into one Ruby process. This month-end report runner loads both anyway, each in its own Carton. Source on GitHub.

Each plugin sends the month’s orders to a service with Faraday, and each is stuck on a different major version:

Plugin Gemfile Why it can’t move
crm faraday ~> 1.10, through its vendored crm_client calls request :basic_auth, which 2.0 removed
webhook faraday ~> 2.0 calls request :authorization, :basic, ..., which 2.0 added

Plain Ruby activates one version of a gem per process. Load crm first and webhook fails:

Gem::LoadError: can't activate faraday-2.15.0, already activated faraday-1.10.6

No single lockfile satisfies both, so Bundler can’t help. The usual fixes are to fork one plugin, run each in its own process, or wait for its maintainer to upgrade. crm also carries a legacy patch that prints every Float with two decimals, which in plain Ruby changes Float#to_s for the host and webhook too.

main.rb finds each directory in plugins/, imports it as a Carton, and calls the plugin interface, title and call(orders):

main.rb
require 'carton'
# A month-end report runner. It has no Gemfile and never loads Faraday; each
# plugin brings its own bundle.
# The orders are frozen because objects are shared across Boxes: a plugin
# could otherwise change them for the host and every other plugin.
ORDERS = [
{ month: '2026-06', state: 'CA', amount: 600.0 },
{ month: '2026-06', state: 'NY', amount: 400.0 },
{ month: '2026-07', state: 'CA', amount: 630.5 },
{ month: '2026-07', state: 'TX', amount: 449.5 },
{ month: '2026-08', state: 'NY', amount: 550.25 },
{ month: '2026-08', state: 'TX', amount: 699.75 },
{ month: '2026-09', state: 'CA', amount: 981.0 },
{ month: '2026-09', state: 'NY', amount: 350.0 },
].map(&:freeze).freeze
# The plugin interface. A plugin is a directory, plugins/<name>/, whose
# Entrypoint lib/<name>.rb exports a module with `title` and `call(orders)`,
# which sends the orders somewhere and returns report lines as Strings.
PLUGIN_METHODS = %i[title call].freeze
plugin_dirs = Dir.glob('plugins/*/', base: __dir__).sort
plugins =
plugin_dirs.map do |dir|
# Carton boundary: each import loads one plugin, and its gems, in a new
# Box. Only its Export comes back. The path is found at runtime, so mark
# it for `carton check`.
plugin = import_relative dir # carton:dynamic
missing = PLUGIN_METHODS.reject { plugin.respond_to?(it) }
raise "#{dir} lacks #{missing.join(', ')}" if missing.any?
plugin
end
plugins.each do |plugin|
puts "== #{plugin.title} =="
puts plugin.call(ORDERS)
puts
end
# Neither plugin's gems nor crm's Float#to_s patch reached the host.
puts '== Host =='
puts "Faraday defined: #{!defined?(Faraday).nil?}"
puts "0.1 + 0.2 = #{0.1 + 0.2}"
main.rb # the host: finds, imports, and runs plugins
plugins/
crm/
lib/crm.rb # Entrypoint: imports crm_client, export Crm
lib/crm/
float_as_money.rb # the Float#to_s patch, inside the Carton
cartons/crm_client/
Gemfile, Gemfile.lock # faraday 1.10.6
lib/crm_client.rb # Entrypoint: setup_bundle, export CrmClient
webhook/
Gemfile, Gemfile.lock # faraday 2.15.0
lib/webhook.rb # Entrypoint: setup_bundle, export Webhook

The host never loads Faraday and has no Gemfile. Adding a plugin is a new directory with its own Gemfile, or with Cartons that have one. Both plugins use Faraday’s test adapter, so the example runs offline.

webhook calls Carton.setup_bundle in its Entrypoint, so it gets Faraday 2.15.0 in its own Box:

plugins/webhook/lib/webhook.rb
# Carton boundary: this Entrypoint and everything it loads run in the webhook
# plugin's own Box. The host only gets the module exported below.
# Bundle boundary: plugins/webhook/Gemfile locks faraday 2.15.0. Bundler
# activates it in this Box only, beside crm_client's 1.10.6.
Carton.setup_bundle
require 'faraday'
module Webhook
URL = 'https://hooks.example.com'
# A stand-in for the real service, so the example runs offline.
STUBS =
Faraday::Adapter::Test::Stubs.new do |stub|
stub.post('/month-end') do |env|
months = JSON.parse(env.body).size
[202, { 'Content-Type' => 'application/json' }, { months: }.to_json]
end
end
private_constant :STUBS
def self.title = "Webhook (faraday #{Faraday::VERSION})"
def self.call(orders)
totals =
orders
.group_by { it.fetch(:month) }
.sort
.to_h { |month, rows| [month, rows.sum { it.fetch(:amount) }] }
response = connection.post('/month-end', totals)
[
*totals.map { |month, total| "#{month} #{total}" },
"POST /month-end #{response.status} #{response.body['months']} months",
]
end
# `request :authorization, :basic, ...` is new in faraday 2, so this plugin
# cannot run on the 1.10 that crm_client needs.
def self.connection
Faraday.new(URL) do |f|
f.request :authorization, :basic, 'reports', 'secret'
f.request :json
f.response :json
f.adapter :test, STUBS
end
end
private_class_method :connection
end
# Export: the plugin interface is this module's `title` and `call`.
export Webhook

crm has no Gemfile. It imports its vendored API client, crm_client, which sets up its own Carton Bundle with Faraday 1.10.6. crm_client is a Transitive Carton: the host reaches it only through crm, and Faraday stays in crm_client’s Box:

plugins/crm/lib/crm.rb
# Carton boundary: this Entrypoint and everything it loads run in the crm
# plugin's own Box. The host only gets the module exported below.
# A file inside this Carton: only this Entrypoint can load it, and the patch
# it makes applies to this Box only.
require_relative 'crm/float_as_money'
# Carton boundary: the vendored CRM client is a Carton of its own, so this
# plugin needs no Gemfile. crm_client sets up its own bundle, and Faraday
# stays in its Box.
CrmClient = import_relative '../cartons/crm_client'
module Crm
def self.title = "CRM (faraday #{CrmClient.faraday_version})"
def self.call(orders)
orders
.group_by { it.fetch(:state) }
.sort
.map do |state, rows|
total = rows.sum { it.fetch(:amount) }
# This Float prints with two decimals because of float_as_money.rb.
"#{state} #{total} #{CrmClient.sync(state, total)}"
end
end
end
# Export: the plugin interface is this module's `title` and `call`.
export Crm
plugins/crm/cartons/crm_client/lib/crm_client.rb
# Carton boundary: crm's vendored API client. It is a Transitive Carton for the
# host, which reaches it only through the crm plugin.
# Bundle boundary: crm_client/Gemfile locks faraday 1.10.6. Bundler activates
# it in this Box only, so the crm plugin and the host never see it.
Carton.setup_bundle
require 'faraday'
module CrmClient
URL = 'https://crm.example.com'
# A stand-in for the real service, so the example runs offline.
STUBS =
Faraday::Adapter::Test::Stubs.new do |stub|
stub.put(%r{\A/accounts/\w+/sales\z}) { [200, {}, 'synced'] }
end
private_constant :STUBS
def self.faraday_version = Faraday::VERSION
def self.sync(account, total)
connection.put("/accounts/#{account}/sales", total: total.to_s).body
end
# Written against faraday 1. `request :basic_auth` was removed in 2.0, so
# this client cannot move to the version the webhook plugin needs.
def self.connection
Faraday.new(URL) do |f|
f.request :basic_auth, 'reports', 'secret'
f.request :url_encoded
f.adapter :test, STUBS
end
end
private_class_method :connection
end
# Export: the client's Public Surface is `faraday_version` and `sync`.
export CrmClient

The Float#to_s patch stays in crm’s Box, so crm_client and webhook print Floats as Ruby does:

plugins/crm/lib/crm/float_as_money.rb
# A legacy patch that prints every Float as money. In plain Ruby this would
# change Float#to_s for the whole process. Here it changes it only in the crm
# Box, so the host, the webhook plugin, and crm_client keep Ruby's Float#to_s.
class Float
def to_s = format('%.2f', self)
end
bundle exec rake example:plugins # installs each bundle, then runs
RUBY_BOX=1 ruby examples/plugins/main.rb
== CRM (faraday 1.10.6) ==
CA 2211.50 synced
NY 1300.25 synced
TX 1149.25 synced
== Webhook (faraday 2.15.0) ==
2026-06 1000.0
2026-07 1080.0
2026-08 1250.0
2026-09 1331.0
POST /month-end 202 4 months
== Host ==
Faraday defined: false
0.1 + 0.2 = 0.30000000000000004

A Carton loads once per process, keyed by its Entrypoint’s path. If both plugins import the same third Carton, such as a shared path gem, they get one copy, built with the gem versions its First Importer selected. When the other plugin’s Carton Bundle locks a different version of a gem that Carton uses, its import raises Carton::ImportError naming the gem and both versions, rather than sharing a copy built against the wrong one. Either lock the same versions in both plugins, or give each its own copy: a Carton at another path, such as crm’s vendored crm_client, is a separate Carton.

Test a plugin the way the host uses it: import its directory and call its interface. test/webhook_test.rb does this with minitest from test/Gemfile, separate from the plugin’s Carton Bundle, so the host still needs no Gemfile:

RUBY_BOX=1 ruby examples/plugins/test/webhook_test.rb

Each Carton loads once per process, so a plugin’s state carries from one test to the next. Keep plugins stateless, or run each plugin’s tests in its own process. To test a plugin’s Interior, require its files directly without Boxes; see Testing.

  • Box is not a security boundary. Plugins run with the host’s full privileges, so load only plugins you trust.
  • Objects are shared, not copied. A plugin can mutate what the host passes it, so the host freezes ORDERS. Pass plain values such as Strings, Numbers, and Hashes: each Box loads its own gem classes, so a Faraday::Response from one plugin is not a Faraday::Response to the host or the other plugin.
  • Box isolates patches to builtins such as Float. Classes a plugin defines are shared, so reopening another plugin’s class changes it everywhere; see Sharing classes and services.
  • ENV is process-wide. setup_bundle restores it, but load plugins while booting, before starting threads that read ENV.
  • Each Box costs tens of milliseconds to create, plus its own gem loading.