Every config value starts the same way: you just need it, right now, and the fastest path is to write it straight into the method that uses it. A credential, a limit, a base URL - hardcoded, because reaching for a whole configuration mechanism for one value feels like overkill, and you don't want to be slowed down by ceremony you don't need yet. That's fine, right up until you try to check the code in. Then it isn't a shortcut anymore, it's a secret sitting in version control, and the discomfort of that moment is usually what finally makes you go build a real config system.
That's where it gets interesting, because the standard shape of "a real config system" has its own two ways of going wrong.
The first is that reading the config file ends up needing more knowledge about the application than anything reading a file should have. Something has to know the whole shape of what's being configured, has to exist before any of the components it's configuring do, and has to be reachable from all of them once it's built. In practice that "something" is usually a singleton, introduced for no reason other than the config file needed a place to live that everything could see. The config mechanism didn't just supply values, it became an architectural decision.
The second failure runs the other way: instead of the config layer knowing about the app, the app's components get taught to know about config files. A component takes a config object, or a path, or a section key, and from that point on it only really works if you hand it configuration in exactly that shape. It's not a component anymore so much as a config-file consumer that happens to do something useful. Test it standalone, reuse it somewhere else, and you first have to reconstruct the thing it was never supposed to depend on in the first place.
Both failures come from the same root: config reading and the component being configured aren't actually independent of each other, even though there's no real reason they shouldn't be.
Software design usually treats global state as something to avoid, and for good reason - unrestricted state that anything can reach and change makes correctness hard to reason about. Configuration gets folded into that avoidance by convention, without ever being named as what it is: nobody calls a config value a "global," but a typical config-loading path behaves like a discipline for keeping it from actually being one. A process starts, reads a file, applies each value to whatever it's initializing, and once that pass is done the values themselves are gone - not deleted, just unreachable, because nothing about the design kept a path back to them. The values were global in every sense that matters, true for the whole running process and not owned by any one part of it, but the mechanism only ever treated them as a one-time delivery, never as something the rest of the system's lifetime might still need to look up.
That's fine right up until it isn't. The moment two components need the same value, either both parse the file themselves or one hands a copy to the other and the two can quietly drift. The moment anything built after startup needs it - a health endpoint, a debug console, a second instance of a component created later, a value an operator wants to change without restarting the process - there's no path back to a value that was only ever pushed forward once and then forgotten. That singleton from the first failure above isn't a design choice so much as a symptom: it's what happens when a value genuinely needs to stay globally reachable and the surrounding design never admitted that's what it needed, so something ad hoc gets built to fake it back in.
Canopy doesn't try to avoid the label. If a value is true for the whole running image, addressable by more than one part of it, and needed for longer than a single initialization pass, it's a global value, and pretending otherwise just relocates the problem instead of solving it. So Canopy keeps values reachable for as long as the image runs, instead of handing them out once and letting them go - which is the whole reason there's a tree living at one known place, the singleton, instead of a one-shot loader that discards its own result. In a deployed application that tree ends up being a values facade for the application as a whole: everything it currently knows about itself, in one place, addressable at any time by name - not because centralizing values is good in the abstract, but because these particular values already belonged together and were already global. Canopy just stops hiding it.
Canopy is a small Pharo foundation package for managing exactly those global values. At its center is a singleton that's also, itself, a tree: Canopy the class is a CanopyBranchNode, and every top-level key registered on it is a domain - an ordinary branch, reachable from anywhere in the image just by name, with no lookup ceremony beyond that name. Underneath a domain, values sit at the leaves. There are two kinds. A CanopyCell just holds a raw value, typically one imported from a config file. A CanopyAccessor doesn't hold a value at all - it binds a position in the tree to a real, living object elsewhere in the image, and asking for it goes and gets whatever that object currently has. That's the whole shape: a tree of named, globally reachable positions, some holding data directly, some pointing at something else that's holding it.
Once you accept that these are global values, the next question is what "global value" actually has to cover - and it turns out to cover both directions a value can move, not just one. A config value flows in: something out there decided what it should be, and a component reads it. A metric flows out: a component knows something about its own current state, and the rest of the system reads that. Both are global in exactly the same sense - true for the whole image, not owned by whoever happens to be asking, needed for as long as the image keeps running rather than for one initialization pass - and before Canopy, both got the same one-shot treatment anyway: a config value pushed in once and forgotten, a metric computed and printed once and forgotten, neither kept around as something the rest of the system could still ask about later.
That's why Canopy doesn't model config and metrics as two different kinds of thing needing two different subsystems. A CanopyAccessor binds a tree position to a living object once; which direction values actually flow through that binding - in, out, or both - turns out to be a property of the binding itself, not of the tree, and not of two separate mechanisms bolted together afterward.
That's the actual brief Canopy was built against: read and component have to be independent, and configuration has to be layered onto a component transparently from the outside, not baked into how the component is written. A component keeps its ordinary getters and setters; something else, added afterward and removable again, is what connects them to a source of values.
The second goal is less obvious but just as deliberate: break the symmetry most config-mapping systems quietly assume. A typical mapper wants the config file and the object graph it's populating to mirror each other - same nesting, same names, one-to-one. That's a strange requirement once you notice it: a config file is a snapshot of what an operator wants to say, and a component tree is a snapshot of what an application happens to be built from, and there's no reason those two shapes should have to agree. A component might need three settings that live in five unrelated places in a big shared config file, or a config file might carry sections that no component here even reads. Forcing symmetry between the two just means every reorganization on one side breaks the other.
That binding is small enough to state in one sentence: a CanopyAccessor connects a position in the tree to a component through two independently optional selectors.
"CanopyAccessor"
valueHolder
"What the read selector answers, wrapped: a plain value becomes a CanopyValue, a holder
answers itself. Only the export path asks for this."
readSelector ifNil: [ ^ self error: 'this accessor has no read selector' ].
^ (object perform: readSelector) asCanopyValue
value: anObject
"Unwrap on the way in, so the component's own write method keeps receiving a raw value
and never has to know about holders."
writeSelector ifNil: [ ^ self error: 'this accessor has no write selector' ].
object perform: writeSelector asMutator with: anObject asCanopyValue value
readSelector calls a method that returns a value - that's the metric role. writeSelector calls a method that takes one - that's the config role. Set only one and you get a read-only or write-only leaf; set both and the same position in the tree is both at once. There's no separate metric node type and no separate config node type, because there's no actual difference between them at the mechanism level - only the direction the selector points.
That inversion is worth sitting with for a second. From the component's point of view, a config value is read-only - the component just has a value sitting there, it never has to do anything to produce it - and a metric is write-only - the component just reports its own state, it never has to do anything to consume it. From the tree's point of view it's exactly reversed: a config leaf is something the tree writes into the component, a metric leaf is something the tree reads out of it. Both descriptions are correct at once; they're just looking from opposite ends of the same accessor.
One more piece sits underneath before any of this reaches a pragma: what a leaf actually holds is never a bare value, it's a holder. A plain value gets wrapped automatically -
"Object"
asCanopyValue
"Answer this object as a Canopy value: wrapped in a holder that carries nothing but the
value itself. A holder answers itself, so wrapping is idempotent."
^ CanopyValue value: self
CanopyValue is the plain case, carrying nothing but the value. A metric needs to carry more than a number - a type, a help string, labels - so it gets its own holder, CanopyMetric, built and returned directly by the method instead of assembled by Canopy afterward. Either way, the holder stays out of sight everywhere except the one place that's actually built for it:"CanopyAccessor"
value
"The raw value. Every reader outside the export path wants this one, so the holder stays
invisible here."
^ self valueHolder value
value always peels the holder back down to a plain value - config reads, apply:, the JSON view, all of them only ever see that. valueHolder is the one door left open to the holder itself, and only the Prometheus exporter walks through it, asking each leaf readingsDo: for whatever it has to contribute. A plain CanopyValue contributes nothing there and is skipped; a CanopyMetric contributes a line. Nothing about that split needed a class check anywhere - a value that doesn't know how to produce a reading simply doesn't, and the export filter turns out to be no filter at all.
The component itself never has to know any of this is happening. Canopy builds the connection through a pragma:
"VirtualMachine"
memorySizeCanopy
<canopyValue: #memorySize>
^ CanopyMetric new
type: #gauge;
description: 'The size of memory';
value: self memorySize
The direction isn't declared, it's inferred from the method's own arity - a unary method can only be a getter, so it can only read; a one-argument method can only be a setter, so it can only write. Declare both directions on the same key and you get one leaf that does both. VirtualMachine used to expose the same underlying number twice - #maxExternalSemaphores, readable, and #maximumExternalSemaphores, writable, two separate keys standing in for one value because a single key couldn't be both. Today it's one:
"VirtualMachine"
maxExternalSemaphoresCanopy
<canopyValue: #maxExternalSemaphores>
^ CanopyMetric new
type: #gauge;
description: 'The maximum of available external semaphores';
value: self maxExternalSemaphores
maxExternalSemaphoresCanopy: anInteger
"The same key as the reading declaration above: one leaf that can be read and written,
instead of the same value sitting twice in the tree under two names."
<canopyValue: #maxExternalSemaphores>
^ self maxExternalSemaphoresSilently: anInteger
None of this touches the component's real API. maxExternalSemaphores and its setter stay exactly what they always were; the …Canopy/…Canopy: pair is a thin projection layer sitting next to them, doing nothing but delegating. Remove Canopy from the image entirely and the component still works, because it never actually depended on it - which is the independence goal from the last section, made concrete rather than just stated.
The pragma-driven side builds a tree automatically out of whatever a component declares - that's the component's own shape, call it the component tree. A config file, read in from JSON, builds a completely separate tree with whatever shape the file happens to have. Getting a value from one into the other is not the same operation as combining two trees of the same kind, and Canopy treats it as two genuinely different things instead of one that's supposed to cover both.
Merge combines two trees into one, structurally - it's what happens when a JSON import needs to land inside the existing tree, or when two components contributing to the same domain both add their own branches. It has one hard rule: anything that isn't a clean structural combination is a conflict, loudly, in both directions - no silent overwrite, no guessing.
Concretely, this is how a component actually ends up configured at all. The moment a component's pragma methods get reflected on, that reflection builds a small tree of its own - a handful of branches and leaves shaped exactly like whatever that one component declared, nothing more. A config reader, separately, parses a JSON file into a tree shaped however the file happens to be nested. Neither tree knows the other exists, and until something merges them, neither is reachable from Canopy at all - they're just two independent trees sitting in memory, one built by reflection, one built by parsing a file.
merge: is double-dispatched, so the incoming node always decides how it inserts itself into the existing one, not the other way around:
"CanopyNode"
merge: aGlobBranch
aGlobBranch mergeInto: self
"CanopyBranchNode"
mergeInto: aGlobBranch
(aGlobBranch isKindOf: CanopyBranchNode) ifFalse: [ ^ self error: 'conflict' ].
aGlobBranch addTags: tags.
nodes keysAndValuesDo: [ :key :value |
aGlobBranch at: key add: self / key ]
"CanopyBranchNode"
at: aString add: aGlobBranch
nodes
at: aString
ifPresent: [ :value | value merge: aGlobBranch ]
ifAbsent: [ self at: aString put: aGlobBranch ]
Walk the component-and-config-file case through that. Say a component declared one branch, #storage, with one leaf underneath, #cacheSize, built through pragma reflection - and the config file, read independently, turns out to carry the very same shape: a #storage key holding a #cacheSize value. Merging the config tree into the component tree recurses into #storage on both sides (it exists on both, so at:add: finds it present and merges one level deeper) and, once it reaches a key that only exists on the incoming side, inserts that node directly - the ifAbsent: branch above - no cell, no accessor, nothing needs to already exist for a merge to succeed there. Where a leaf already exists on both sides, a plain value merges through CanopyCell>>merge::
"CanopyCell"
merge: aGlobNode
(aGlobNode isKindOf: CanopyCell) ifFalse: [ ^ self error: 'conflict' ].
self value: aGlobNode value.
tags addAll: aGlobNode tags
which just means: if both sides agree on being plain values, the incoming one wins, and its tags are folded into the existing set rather than replacing it. A CanopyAccessor is different - it's bound to one specific living object through one specific pair of selectors, and there's no sensible way to "merge" that with anything else, so the default for every leaf kind other than CanopyCell is a flat, immediate conflict:
"CanopyLeafNode"
mergeInto: aGlobValueHolder
"Default conflict for leaf types with no defined merge semantics (Accessor,
DictionaryAccessor, Metric - they bind to a live object, merging them makes no sense).
CanopyCell overrides this itself since it DOES have real merge semantics."
self error: 'conflict'
with one narrow exception: two components registering the exact same accessor twice - same object, same selectors - isn't really a conflict, it's the same declaration happening again, which is precisely what happens every time an image's startup code runs a second time:
"CanopyAccessor"
mergeInto: aNode
(self declaresSameAs: aNode) ifTrue: [ ^ aNode ].
^ super mergeInto: aNode
What falls out of all of this is the actual shape of a running image's configuration: there was never "the component's tree" and "the config" as two different kinds of thing. There were always just two trees, built by different means - one by reflecting over pragma methods, one by parsing a file - and merge is the one, uniform operation that lands either kind inside the other, failing loudly the moment two sides disagree about what a position should hold instead of quietly picking a winner.
Apply is different on purpose. It walks the component tree and, for every key that also exists in a given values tree, pushes that value in - and a key present in the values tree with nothing to match it in the component tree is silently ignored:
"CanopyBranchNode"
apply: aGlobBranch
aGlobBranch keysAndValuesDo: [ :key :value |
(value isKindOf: CanopyBranchNode)
ifTrue: [
nodes
at: key
ifPresent: [ :sn | sn apply: value ] ]
ifFalse: [
nodes
at: key
ifPresent: [ :sn | sn value: value value ] ] ]
That one design choice is what actually breaks the symmetry requirement. A config file can carry a hundred keys and a component that only cares about three of them applies cleanly, using exactly the three it recognizes and ignoring the rest without complaint. The file doesn't have to be trimmed to match the component, and the component doesn't have to grow phantom fields just to keep a mapper happy. The two trees are related by what they happen to have in common at apply time, not by being required to look alike.
Once config and metrics share one mechanism, some questions that used to need their own answer just don't come up anymore. Should a value be exportable? Ask nothing extra - a leaf that answers to a metric holder produces export lines, a leaf that only ever returns a plain value produces none, and nobody has to flag anything by hand. Does a value need labels, a type, a help string? That's not a property of the tree position, it's part of whatever the read side returns - the tree doesn't need to know it's looking at a metric at all.
That's the whole mechanism: one binding, two independently optional directions, and two deliberately different ways of combining trees - merge for structure, apply for values. Two things are still open. What happens when two sources - a config import and a component's own metrics, say - genuinely need to share one subtree and still be told apart afterward? And once a value is honestly global inside the image, is there a reason it should stop being reachable at the image's own process boundary? Both build directly on what's here, and both are worth their own piece - a follow-up, still to come.
The code is on github if you want to read ahead.