Composing a Terret (v1)
Plan §7 is four bullets. This is those four bullets made normative, and it
is the contract gems/terret is built against when it stops being a
placeholder holding the name.
Everything in Terret is a plugin, which is a claim that only means something
if there is a way to say which plugins, in what order, with what config,
without editing Ruby. Bundles ship rows, profiles stack bundles, patches
adjust rows, and Terret.boot hands the result to the Hames loader. The
model is a direct port of dsh’s (plan §2.2), YAML-native.
1. The row is the unit
A running Terret is an ordered list of config rows, and the loader mounts
them in dependency order derived from each service’s inject declarations.
A row is four fields:
- id: sandbox # unique; what a patch targets
plugin: Terret::Sandbox::Docker # the service class that mounts it
config: { network: none } # handed to the service wholesale
disabled: false # present but not mounted
id is the addressing scheme for everything below: a patch names a row by
id, dump-config reports by id, doctor reports by id. plugin names the
Ruby class. doctor (§9) is where a row whose constant does not resolve
should surface, before a boot gets halfway through. disabled: true keeps a
row visible in the resolved tree while leaving it unmounted, which is how the
base bundle can ship an approvals row that an autonomous profile simply never
turns on (§6).
Everything else in this document is a way of producing that list.
2. Bundles
A bundle is a gem that ships config/bundle.yml, an ordered list of
rows. It does not have to contain the code those rows mount. It has to make
that code available, which for a Ruby gem means depending on the gems the
rows name. That distinction is what lets terret-base (§6) live inside the
meta-gem while mounting services from six other gems: it ships the rows and
declares the dependencies, and the classes resolve because Bundler put them
on the load path. A bundle declares itself in its
gemspec:
s.metadata = { "terret" => "config/bundle.yml" }
The value is the path on its own because RubyGems validates every metadata
value as a String: a gemspec carrying a nested hash there does not build at
all (metadata['terret'] value must be a String). Discovery still accepts
the richer forms on read, both a real Hash and a YAML mapping inside the
string, so a gem that grows a second key later does not break. The path alone
is what a bundle should ship.
That single line is the whole registration mechanism, and choosing gemspec
metadata over a registry file or a plugin directory is deliberate: gem
install is already the install step and Gemfile is already the manifest;
Bundler already resolves versions. Discovery walks every gemspec
Gem::Specification knows about (under Bundler that is the bundle; outside
it, every gem installed on the machine, loaded or not), reads the key, and
parses the file it points at. A third-party gem becomes discoverable by
shipping normally; nothing needs to be registered, symlinked, or dropped into
a special directory. It is the port of dsh’s package.json dsh field (plan
§5); docs/cookbook/adding-a-bundle.md walks the mechanism end to end.
Discovery quarantines what it finds. A gem whose bundle.yml does not parse,
or whose metadata points outside its own gem directory, becomes a broken
entry that only a profile naming it ever sees. One bad gem in the Gemfile
must not take out every profile on the machine.
The bundle file itself is that ordered list of rows, optionally wrapped in a mapping that also carries a name and its requires:
name: terret-base # what dump-config calls this layer
requires: # loaded before any row's constant resolves
- terret/store/sqlite
- terret/exec
rows:
- id: session_store
plugin: Terret::Store::SQLite
requires: is the working half of “it has to make that code available”.
Depending on the gem is what puts it on the load path; a load path is not a
require, and Object.const_get("Terret::Store::SQLite") fails on a gem
nobody has loaded. So a bundle lists the files its rows’ classes live in, and
boot requires them before resolving a single constant. A profile’s plugins:
(§3) does the same job for code that is not a bundle at all.
A profile names bundles by gem name. A name that discovery did not find
fails closed, listing what was discovered. The failure mode here is almost
always a gem that is not installed in this environment, or not in this
Gemfile, or one that ships no terret metadata at all, and printing the
found set turns a five-minute confusion into a five-second one.
3. Profiles
A profile is a named composition living in Terret home:
~/.terret/
├── patch.yml # applies to every profile (§4)
└── profiles/
└── headless/
├── profile.yml
└── patch.yml
# ~/.terret/profiles/headless/profile.yml
bundles:
- terret # terret-base, always layer one
- terret-fortune # a third-party bundle
plugins:
- terret/fortune # out-of-tree requires, loaded before rows resolve
settings:
sandbox:
image: terret/sandbox:latest
model:
main: anthropic/claude-opus-4.5
bundles: is the stack, in order, and plugins: names requires for code
that is not a bundle. settings: is a plain map with no schema of its own;
its only job is to be the target of !setting references (§5), so a value
used by three rows gets written once.
patch.yml sits beside profile.yml and applies after every bundle in
the stack (§4).
TERRET_HOME overrides ~/.terret wholesale. That is what makes the
whole layer stack testable: every composition test points it at a tmpdir. It
also lets a deployment ship a home directory as an artifact instead of
instructions for populating a user’s dotfiles.
4. Layer order and patches
Four layers, applied in this order:
- every bundle in the profile’s
bundles:list, in listed order - the profile’s own
patch.yml ~/.terret/patch.yml, the home-level patch, applying to every profile--patch FILEoverlays on the command line, in the order given
Later layers win. The ordering reads outward from shared to specific: a
bundle is what a gem author decided, a profile patch what this composition
decided, a home patch what this machine decided, and a --patch overlay what
this single invocation decided.
A patch file is a list of rows:
# ~/.terret/profiles/headless/patch.yml
rows:
- id: sandbox
config: { image: "terret/sandbox:latest", network: none }
- id: llm
config: { model: !setting model.main }
- id: audit
plugin: Acme::Audit
after: tools
config: { sink: !env AUDIT_URL }
A patch targeting an existing id replaces that row’s config wholesale. It
never deep-merges. This is the same rule the kernel’s reconfigure contract
already follows. Deep merging makes unsetting a key inexpressible (there is
no YAML for “and remove network”), and it makes the effective value of any
key a function of the entire stack, so reading one file never tells you what
a service will get. Wholesale replacement means the last layer that mentions
a row is the answer, whole.
The llm row above is that rule biting, kept in the example on purpose: the
base bundle’s row carries an api_key: !env … alongside its model, and a
patch that mentions only model: drops the key. The adapter then boots
without one. This is the mode of failure to expect from wholesale
replacement. The cure is to restate the whole config; dump-config (§10)
exists precisely to show what a row currently holds before a patch replaces
it.
A patch may also swap plugin: on an existing id, which is the mechanism
docs/exec.md §4 leans on: one row swaps Terret::Exec::SandboxNone for
Terret::Sandbox::Docker and every tool built on ctx[:fs] and
ctx[:subprocess] moves into the container, tool code untouched.
Swapping plugin: while saying nothing about config: forwards the old
config to the new class, whatever it held (an !env-resolved key, a
workspace list, a path), because replacement is per field and a patch that
mentions one field replaces one field. That is usually what you want, and
occasionally it is not; dump-config reports the plugin and the config layer
separately (§10), so a row inheriting a config from a layer that never meant
it for this class is visible. A swap that should inherit nothing has to say
config: {} out loud.
A row with an id that does not exist yet is an insertion, and it must say
where it goes with before: or after: naming an existing id. An anchor
naming an id that is not in the stack fails closed, naming the id. It does
not fall back to appending the row somewhere plausible.
What position controls is the tree, not the mount order and not listener
order. These anchors decide where a row sits in the resolved list, which is
what dump-config prints, what a later patch reads, and what a human
reviews. The loader then mounts in dependency order derived from inject
(§1), so a row’s position here does not decide when it mounts. Because
listeners register as their row mounts, position does not decide the order of
two tools/pre_execute listeners either. A profile that needs an audit
listener to run in front of the thing it audits cannot express that with
before: today. Deterministic listener ordering is a known gap, not a
guarantee this format makes.
5. Tagged scalars
Config is data first. Three tags make it dynamic without making it code:
config:
api_key: !env OPENROUTER_API_KEY # ENV fetch; nil when unset
image: !setting sandbox.image # the profile's settings map
weird: !ruby "Etc.nprocessors" # refused unless --allow-config-ruby
!env resolves at mount time and answers nil when the variable is unset. It
returns nil instead of raising, because “no key configured” is a state a
service should be allowed to have an opinion about (the adapter that wants
one raises a better error than YAML can), and because doctor and
dump-config need to be runnable on a machine that holds no secrets at all.
!setting takes a dotted path into the profile’s settings: map and fails
closed on a missing path. The asymmetry with !env is intentional: an
unset environment variable is an ordinary deployment state, while a
!setting pointing at nothing is a typo in a file the profile author
controls.
!ruby is refused unless --allow-config-ruby is passed, and evaluated in a
clean binding when it is. The flag is the consent: config that can execute
arbitrary Ruby is code with a YAML extension, and a profile downloaded from
anywhere should not be able to run without the operator having said so out
loud.
How those three tags are read matters, because the obvious implementation
does not work. YAML.safe_load drops a local tag silently:
permitted_classes: gates Ruby-object tags like !ruby/object:Foo, not
application tags like !env, so a document loaded that way comes back with
the tag gone and the bare scalar in its place. !env OPENROUTER_API_KEY
would resolve to the string "OPENROUTER_API_KEY" and boot a service with
a literal nonsense key. So resolution is explicit: Psych.parse to an AST,
then a visitor that walks it and resolves !env, !setting, and !ruby
nodes by tag, refusing any other tag it meets. YAML.load never appears
anywhere in the path, either, so an untrusted profile cannot instantiate
arbitrary Ruby objects at parse time regardless of what the visitor does
afterward.
6. terret-base, and secure by default
terret-base is a bundle inside the meta-gem
(gems/terret/config/bundle.yml), not a gem of its own: plan §5’s layout
lists no such gem and plan §7 puts it inside terret. It is layer one of
every profile, and its rows are the answer to “what is a Terret”:
| Row | Plugin | Note |
|---|---|---|
session_store |
Terret::Store::SQLite |
durable log, WAL |
sessions, prompt, tools, loop |
terret-core | the harness itself |
llm |
Terret::LLM::Service |
the role map: main: and whatever else a profile points somewhere |
openrouter |
Terret::OpenRouter::Plugin |
registers the adapter under the provider name openrouter, !env-keyed |
fs |
Terret::Exec::FS |
an empty or unconfigured workspace: list denies every fs op |
| subprocess / shell / terminals / jobs | terret-exec | the rest of the execution world |
sandbox |
Terret::Sandbox::Docker |
network: none |
| std tools | terret-tools-std | the CC-named roster |
subagents, std_task |
terret-core, terret-tools-std | Task delegation and the agents it spawns |
redactor |
terret-core | tools/post_execute + the append scrubber |
allow_list |
Terret::Tools::AllowListFloor |
the deny-by-default floor: a thin service over the AllowList module, so the floor is a row like everything else |
approvals |
terret-core | disabled: true (opt-in per M6) |
The model seam is two rows, not one. The split is deliberate.
Terret::LLM::Service is the seam: it holds the role map and it is what
ctx.llm resolves to. An adapter is not a service and mounts nothing;
Terret::OpenRouter::Plugin injects :llm and registers an
OpenRouter::Adapter into it under the provider name openrouter, which is
the openrouter/ half of a role like main:
openrouter/anthropic/claude-sonnet-4.5. Swapping providers is therefore a
second row, not a rewrite of the first. Taking a profile offline is
disabled: true on the adapter row plus a role pointing somewhere else.
The sandbox row is the one to read twice. The default is docker with
network: none (plan §13, docs/security.md), so the trusted world is
something a profile opts into. It is not a default a profile forgets to opt
out of. The shipped headless profile template makes that opt-in look like
what it is:
# ~/.terret/profiles/headless/patch.yml
rows:
# ------------------------------------------------------------------
# UNCOMMENT TO RUN TOOLS DIRECTLY ON THIS HOST, UNSANDBOXED.
# Every Bash command, every spawned process, and every terminal this
# agent opens runs as you, on your machine, with your network. A
# prompt-injected instruction that reaches a tool call has nothing
# between it and the host but the allow list. See docs/security.md.
# ------------------------------------------------------------------
# - id: sandbox
# plugin: Terret::Exec::SandboxNone
# config: {}
A comment block is not a security control. It marks the difference between a
decision made and a default inherited, and plan §13 asks for exactly that:
none requires explicit per-profile opt-in. The template is where the
explicitness lives.
The fs row deserves the same second read for the opposite reason: its
workspace: list is what every filesystem tool is contained to, and an empty
or unconfigured list denies every fs operation (docs/exec.md §3,
docs/security.md). There is no ungranted-but-permitted state, so a profile
that forgets the row gets an agent that cannot read a file: the safe failure,
and a confusing one if nobody says so in advance.
7. Terret.boot
ctx = Terret.boot(profile: "headless",
patches: [], # --patch overlays, in order
allow_config_ruby: false,
home: nil) # defaults to TERRET_HOME or ~/.terret
It resolves the layers (§4), hands the row list to the Hames loader, and
returns the booted context. That is the whole surface, and its shape is plan
§1’s embeddability goal made concrete: a Rails app calls Terret.boot in an
initializer and holds the ctx, with no process to supervise and no socket
to speak. The trt executable is one caller of this method. It is not the
way Terret is used.
Resolution itself is pure: YAML in, ordered rows plus provenance out, nothing
mounted. That separation is what lets dump-config and doctor report on a
composition they never boot, on a machine with no Docker daemon and no API
key.
8. trt
trt boot --profile NAME [--patch FILE]... [--allow-config-ruby]
trt doctor --profile NAME
trt dump-config --profile NAME
trt acp --profile NAME # docs/acp.md, docs/zed.md
Non-interactive, optparse, no thor, no REPL, no TUI. Plan §1’s non-goals bar
an interactive text CLI (a human-facing terminal UI that is a way of
talking to an agent) and do not bar an executable. Nothing here is a chat
window, and none of it competes with the socket (plan §9): trt boot starts
the reactor and parks, trt acp serves an editor over stdio, the other two
print and exit, and every one of them is a thin wrapper over Terret.boot or
over pure resolution.
9. doctor and Hames::Schema
trt doctor resolves a profile and validates every row’s config against
its plugin’s schema declaration, without booting anything.
Hames::Schema is the kernel’s own tiny config validator: a plain
description of a service’s config keys (for each one a type:, whether it is
required:, an optional enum: of legal values, a default:, and a doc:
string), plus the code that checks a config hash against that description and
reports what does not fit. Services declare against it with the
config_schema class method, which stores the description on the class
(inherited by subclasses) where doctor and the catalog generator can both
read it. It is pure stdlib, not dry-schema: the kernel’s
zero-runtime-dependency rule is a deliberate constraint.
class Docker < Hames::Service
service_key :sandbox
config_schema image: { type: String, required: true, doc: "container image" },
network: { type: String, enum: %w[none bridge host], default: "none",
doc: "docker --network mode" }
end
Two semantics govern how strict this is, and both err the same direction:
- A service with no schema is reported, not failed. Doctor marks it
unschema'dand moves on. Every first-party service declares a schema (an empty one,config_schema({}), when it reads no config, which still reads as audited,ok), sounschema'dnow carries real signal: an external or unaudited plugin that declared none. A doctor that failed on every un-annotated row would be a doctor nobody could run when a third-party bundle ships one. - Extra keys warn, not fail. Config rows grow, and a row carrying a key from a newer version of a gem should be a warning about drift, not a boot that refuses.
Environment probes are printed as informational lines and never as
failures. Doctor validates config, not the world. It reports each !env
marker the composition reads and whether it resolves (the state a machine
holding no secrets is in), because a doctor that goes red on a laptop without
a key set is a doctor whose red means nothing within a week, and the entire
value of the command is that its exit status can be trusted in CI. (A live
daemon probe, such as whether Docker is up, is deliberately not run because
it would be slow and non-deterministic.) Exit status is 1 when an enabled
row’s config is wrong, and 0 otherwise; a disabled row cannot mount, so its
config never flips the status.
$ trt doctor --profile headless
row plugin status
session_store Terret::Store::SQLite ok
sandbox Terret::Sandbox::Docker ok
llm Terret::LLM::Service ok
titler Terret::Titler ok
audit Acme::Audit error: sink must be a String, got nil
metrics Acme::Metrics unschema'd
info OPENROUTER_API_KEY: unset
The same declarations generate docs/config-catalog.md via rake
config:catalog, which CI diffs exactly the way it diffs docs/events.md. A
config key that changes shape shows up in review, not in a support thread.
10. dump-config
trt dump-config prints the resolved tree with each row annotated by
the layer that contributed it:
# resolved: profile "headless"
rows:
- id: session_store # row: terret-base
plugin: Terret::Store::SQLite
config: # config: terret-base
path: !setting store.path
- id: sandbox # row: terret-base
plugin: Terret::Sandbox::Docker
config: # config: profiles/headless/patch.yml
image: terret/sandbox:latest
network: none
- id: llm # row: terret-base
plugin: Terret::OpenRouter::Adapter
config: # config: ~/.terret/patch.yml
api_key: !env OPENROUTER_API_KEY
model: !setting model.main
That llm row is the §4 example after the cure. A patch repointing the model
has to carry the api_key along with it, because it replaces the base row’s
config whole. This output is how you would have caught it, had it not.
Provenance is per row, not per key, and that falls straight out of §4: because a patch replaces a config wholesale, there is no per-key blame to assign. Exactly one layer is responsible for what a service receives. Wholesale replacement bought a debuggable tree, which is most of why it is worth its ergonomic cost.
A swapped plugin: is attributed on its own line, because a patch may
change what a row mounts without touching its config and those are two
different decisions by two possibly different layers:
- id: sandbox # row: terret-base
plugin: Terret::Exec::SandboxNone
# plugin: profiles/headless/patch.yml
config: {} # config: profiles/headless/patch.yml
The annotation appears only where a layer swapped something, so its presence means “somebody changed this,” not noise on every row. That line is the one piece of provenance nobody can afford to have wrong: it is how a reader sees that the sandbox got turned off, and which file did it.
Secrets render as their unresolved tag. api_key: !env
OPENROUTER_API_KEY prints as written; the resolved value never appears in
dump-config output at all. This output exists to be pasted into an issue, a
chat thread, or a support ticket, and a command whose whole purpose is “show
me what my config is” must be safe to run in front of other people. A
resolved credential printed once is a credential rotated.