Cookbook: Adding a Bundle
A bundle is a gem that ships an ordered list of config rows, so a
profile can stack the capability by naming the gem instead of editing Ruby.
docs/cookbook/adding-a-tool.md built a fortune tool and
docs/cookbook/adding-a-provider.md built a seam implementation; both end
with “a profile mounts it,” and a bundle is how a gem carries the rows that
mounting needs. This page is the how-to. docs/composition.md is the
normative reference — §2 on bundles, §3 on profiles, §4 on layer order —
and where this page is a recipe, that one is the contract; read it for the
rules, read this for the steps.
The running example stays terret-fortune from the tool cookbook, carried
the last mile into a running Terret.
1. Declare the gem a bundle
A gem announces itself a bundle with one line of gemspec metadata: the string path to its bundle file.
# terret-fortune.gemspec
s.metadata = {
"terret" => "config/bundle.yml"
}
The value is the bare path as a String, and that spelling is not
optional. RubyGems validates every metadata value as a String, so a nested
hash there — the form earlier drafts of the plan showed,
{ "bundle" => "config/bundle.yml" } — does not build at all
(metadata['terret'] value must be a String). Discovery still accepts the
richer forms on read — a real Hash on an in-memory spec, or a YAML mapping
inside the string, so a gem that later grows a second key does not break —
but what a bundle should ship is the path on its own
(docs/composition.md §2). The real meta-gem does exactly this; see the
"terret" => "config/bundle.yml" line in gems/terret/terret.gemspec.
Choosing gemspec metadata as the registration mechanism is deliberate:
gem install is already the install step and Gemfile is already the
manifest, so a bundle becomes discoverable by shipping normally — nothing to
register, no directory to drop a file into (docs/composition.md §2).
2. Depend on what the rows mount
This is the half that is easy to forget: a bundle does not have to contain
the code its rows mount, but it has to make it available. For a Ruby gem
that means depending on the gems the rows name, so Bundler puts their
classes on the load path (docs/composition.md §2):
# terret-fortune.gemspec
s.add_dependency "terret-core", "~> 0.1"
terret-fortune’s one row mounts Terret::Fortune::Tool, which lives in
this same gem, and the tool needs Terret::Tools::Registry from
terret-core — so terret-core is a dependency. The meta-gem is the larger
illustration: terret-base ships rows that mount services from six other
gems, and gems/terret/terret.gemspec lists all six as dependencies for
exactly this reason. A bundle whose row names a class no dependency puts on
the load path fails when that constant has to resolve, which is what §3’s
requires: is about.
3. Write the bundle file
The bundle file is an ordered list of rows, optionally wrapped in a mapping that also names the layer and lists its requires:
# config/bundle.yml
name: terret-fortune # what dump-config calls this layer
requires: # loaded before any row's constant resolves
- terret/fortune
rows:
- id: fortune
plugin: Terret::Fortune::Tool
Two fields carry weight beyond the rows:
name:is the labeltrt dump-configattributes this layer’s rows to. It defaults to the gem name when omitted.requires:is the working half of “make the code available.” Depending on the gem (§2) puts it on the load path, but a load path is not arequire—Object.const_get("Terret::Fortune::Tool")fails on a file nobody loaded. So the bundle lists the files its rows’ classes live in, and boot requires them before resolving a single constant (docs/composition.md§2). The base bundle’srequires:block ingems/terret/config/bundle.ymlis the worked example — one entry per gem whose classes its rows name.
A row itself is the four fields the loader’s unit always is — id, plugin,
config, disabled (docs/hames-primer.md §5, docs/composition.md §1).
terret-fortune needs only id and plugin; a bundle with config-bearing
rows writes them here, and a profile patches them by id later (§5).
Remember s.files has to carry the bundle file: a gemspec globbing only
lib/**/*.rb ships no config/bundle.yml, and the gem is silently not a
bundle. terret-fortune’s glob is Dir["lib/**/*", "config/*.yml"] for
exactly this (and for the vendored fortune list — see the tool cookbook §2).
4. How discovery finds it
Terret::Composition.discover_bundles
(gems/terret/lib/terret/composition.rb) is the mechanism. It walks every
gemspec Gem::Specification knows about — under Bundler that is your bundle;
outside it, every gem installed on the machine, loaded or not — reads the
terret metadata key, resolves the path it points at, and parses the file:
Terret::Composition.discover_bundles
# => { "terret" => #<Bundle …>, "terret-fortune" => #<Bundle …>, ... }
# keyed by gem name
Three properties of discovery are worth knowing before you rely on it, all
grounded in the source of discover_bundles
(gems/terret/lib/terret/composition.rb):
- A bundle that cannot parse becomes a broken entry, not a crash. A
gem whose
bundle.ymldoes not parse — aStandardErrorinsideload_bundle— is caught and turned into aBundle.brokenentry rather than an exception; a profile that never names it never sees it, and one that does name it fails closed at resolve with a “could not be read” error carrying the parse failure. One bad gem in the Gemfile must not take out every profile on the machine (docs/composition.md§2). This is pinned bytest_a_gemspec_that_makes_no_sense_is_not_a_bundle_and_not_a_crash(which also allows a thoroughly malformed metadata value to be simply absent rather than broken — either way, never a usable bundle). - A path that resolves outside the gem’s own directory is silently
excluded — not broken. The
next unless file.start_with?("#{root}/")guard aborts the entry before it is added to the catalog, so a metadata value like../outside.ymlleaves the gem absent from discovery, indistinguishable from a gem shipping noterretmetadata at all — a profile naming it gets the generic “unknown bundle; discovered: …” message, not the broken-entry error. That is a different failure mode from the parse case above, and worth not conflating. Treat this as best-effort rather than a security boundary: it is a string-prefix check, and full bundle-path containment is a known gap for a later security pass (Task 11) that fails open today — the case of a gemspec reporting no gem path at all is leftskipped in the suite (test_a_gemspec_with_no_gem_path_is_not_a_bundle, marked “bundle-path containment (this fails open today)”). - The monorepo checkout is seeded first, then an installed gem wins.
terret-baseis loaded from the checkout so a monorepo run resolvesterretwith no gem installation, and an installedterretgemspec overwrites it when both are present.
Discovery is pure — YAML in, a catalog of bundles out, nothing mounted — so
trt dump-config and trt doctor can report on a composition they never
boot (docs/composition.md §7). You rarely call discover_bundles
directly; Terret.boot and the resolver call it for you (§5).
5. Stack it in a profile
A profile names bundles by gem name, in stack order, layer one always
terret (docs/composition.md §3):
# ~/.terret/profiles/headless/profile.yml
bundles:
- terret # terret-base, always first
- terret-fortune # this gem, its rows layered on top
Terret.boot(profile: "headless") resolves the layers and hands the row
list to the Hames loader. The layer order is fixed and later layers win
(docs/composition.md §4): every bundle in bundles: order, then the
profile’s own patch.yml, then the home ~/.terret/patch.yml, then any
--patch overlays. A bundle naming a name discovery did not find fails
closed, listing what was discovered — almost always a gem that is not
installed, not in this Gemfile, or ships no terret metadata
(docs/composition.md §2).
Two things from the other cookbooks land here:
- A row’s config is patched by a profile wholesale — a patch that
mentions one key drops the rest of that row’s config (
docs/composition.md§4). This is the single rule that surprises people most; when in doubt,trt dump-configshows what a row currently holds before you replace it. - Mounting
terret-fortunedoes not makefortunecallable — the deny-by-default allow list denies it until a profile permits it, and that patch isdocs/cookbook/adding-a-tool.md§5. A bundle ships rows; a profile still decides what the agent may do with them.
Not every gem should ship a bundle. A provider gem meant to be mounted by a
row someone else writes — terret-morph is the example, and it ships no
terret metadata (docs/cookbook/adding-a-provider.md §3) — is discoverable
as a library, not a bundle, and a profile mounts it with a requires:
entry and a row rather than by naming it in bundles:. Ship a bundle when
your gem is a capability a profile should be able to stack by name; skip it
when your gem is one class a row elsewhere mounts.
6. What you have when this is done
A gem that declares itself a bundle in one line of metadata, depends on what
its rows mount, ships an ordered bundle.yml with the requires its
constants need, and is discoverable by shipping normally. A profile stacks
it by name, patches its rows wholesale, and — for tools — permits them in
the allow list. For the rules behind every step here, docs/composition.md
is the reference this recipe is drawn from; for the two things a bundle
usually carries, docs/cookbook/adding-a-tool.md and
docs/cookbook/adding-a-provider.md.