<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>PHP Boy Scout — From one framework to fifty modules</title><link>https://phpboyscout.uk/topics/from-one-framework-to-fifty-modules/</link><description>How go-tool-base was broken into standalone Go modules: grading every package, the ones that stayed, the playbook, the config rewrite that freed the rest, and the recurring bill for the split.</description><generator>Hugo</generator><language>en-GB</language><copyright>Matt Cockayne</copyright><lastBuildDate>Thu, 08 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/topics/from-one-framework-to-fifty-modules/index.xml" rel="self" type="application/rss+xml"/><item><title>I graded every package for how badly it wants to leave</title><link>https://phpboyscout.uk/i-graded-every-package-for-how-badly-it-wants-to-leave/</link><pubDate>Tue, 29 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/i-graded-every-package-for-how-badly-it-wants-to-leave/</guid><category>go-tool-base</category><category>Pioneering</category><description>Twenty-seven packages, four marks each, out of ten. The interesting column wasn't the one I expected, and one row explained the whole programme.</description><content:encoded>&lt;p&gt;I sat down one weekend and gave twenty-seven packages a mark out of ten, four times each. A hundred and eight numbers, in one table, about my own code (which is either diligence or a cry for help, I haven&amp;rsquo;t decided).&lt;/p&gt;&#10;&lt;h2 id="not-the-reason-youd-assume"&gt;Not the reason you&amp;rsquo;d assume&#10;&lt;/h2&gt;&lt;p&gt;The second paragraph of that report is a disclaimer, and I put it there on purpose because I could see where this was heading:&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;The goal is not to break GTB apart. The goal is to identify components with clear reuse value outside the framework, then describe the broad decoupling work required to extract them without hollowing out GTB&amp;rsquo;s integrated experience.&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://gtb.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;go-tool-base&lt;/a&gt; is an all-in-one framework and I still want it to be one. Type &lt;code&gt;gtb generate project&lt;/code&gt; and you should get a thing where the logging, the config, the transport and the CLI already know about each other. That integration &lt;em&gt;is&lt;/em&gt; the product.&lt;/p&gt;&#10;&lt;p&gt;But I&amp;rsquo;d started wanting pieces of it elsewhere, and &lt;a class="link" href="https://phpboyscout.uk/why-i-hand-rolled-every-module/" &gt;signing had already left the building&lt;/a&gt;. So the question was less &amp;ldquo;how do I dismantle this&amp;rdquo; and more &amp;ldquo;which of these has a life outside, and what would it cost to give it one&amp;rdquo;. You can answer that by instinct, of course. I&amp;rsquo;ve watched people do it, I&amp;rsquo;ve done it myself, and what you get is whatever happened to be most annoying that week.&lt;/p&gt;&#10;&lt;h2 id="four-columns-and-just-the-one-surprise"&gt;Four columns, and just the one surprise&#10;&lt;/h2&gt;&lt;p&gt;So it got scored. All twenty-seven, out of ten, on four axes:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Extraction.&lt;/strong&gt; The overall recommendation. Should this be its own module at all?&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Package value.&lt;/strong&gt; What&amp;rsquo;s it worth to someone who wants it &lt;em&gt;without&lt;/em&gt; the framework?&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Code quality.&lt;/strong&gt; Cohesion, testability, API shape, how grown-up it is today.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Ease of decoupling.&lt;/strong&gt; How much framework-specific coupling has to come out first.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The first three are the ones you&amp;rsquo;d expect, and they mostly agree with each other. Good code tends to be worth having, and things worth having tend to have been looked after. The fourth column is the one that earns its keep, because it&amp;rsquo;s measuring something else: not &lt;em&gt;should this leave&lt;/em&gt; but &lt;em&gt;can this leave yet&lt;/em&gt;. Those are different questions and the table is only useful because it keeps them apart.&lt;/p&gt;&#10;&lt;h2 id="four-rows-from-the-table"&gt;Four rows from the table&#10;&lt;/h2&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Package&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Extraction&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Value&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Quality&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Ease&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;chat&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;9&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;7&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;redact&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;9&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;9&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;8&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;regexutil&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;8&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;8&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;9&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;telemetry&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;6&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;7&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;6&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;&lt;code&gt;chat&lt;/code&gt; is the highest-value package in the framework. A Go library that talks to several AI providers with one interface is &lt;em&gt;exactly&lt;/em&gt; the thing people want without a CLI framework attached. Ten out of ten, no argument.&lt;/p&gt;&#10;&lt;p&gt;It also scored four for ease, the joint-worst in the table. Because at that point &lt;code&gt;chat&lt;/code&gt; reached into &lt;a class="link" href="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/" &gt;props&lt;/a&gt;, the GTB HTTP helpers, the config and credentials abstractions, and the logger. Each of those was a small sensible line on its own, and together they had the package thoroughly bolted to the floor. So the most valuable thing I owned was the hardest to shift! Meanwhile &lt;code&gt;redact&lt;/code&gt; and &lt;code&gt;regexutil&lt;/code&gt;, both tens for ease, would have taken a morning apiece and delivered a fraction of the value.&lt;/p&gt;&#10;&lt;p&gt;So value and ease aren&amp;rsquo;t correlated, which is obvious once it&amp;rsquo;s written down, and invisible until you score them separately, because instinct blends the two into one vague feeling that we should probably do something about chat. That feeling can&amp;rsquo;t tell you whether to do it first or last.&lt;/p&gt;&#10;&lt;h2 id="what-the-numbers-were-actually-for"&gt;What the numbers were actually for&#10;&lt;/h2&gt;&lt;p&gt;Sequencing, as it turned out, more than judgement. Once the two questions are separated, the running order writes itself, and the report ends with six steps:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Low-coupling leaf utilities. &lt;code&gt;redact&lt;/code&gt;, &lt;code&gt;regexutil&lt;/code&gt;, &lt;code&gt;browser&lt;/code&gt;, &lt;code&gt;workspace&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;User-facing CLI helpers. &lt;code&gt;output&lt;/code&gt;, &lt;code&gt;forms&lt;/code&gt;, &lt;code&gt;logger&lt;/code&gt;, &lt;code&gt;changelog&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Security and runtime foundations. &lt;code&gt;credentials&lt;/code&gt;, &lt;code&gt;authn&lt;/code&gt;, &lt;code&gt;tls&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;controls&lt;/code&gt;, then &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;grpc&lt;/code&gt; onto it as optional transport adapters.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;vcs/release&lt;/code&gt; and the provider adapters.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;chat&lt;/code&gt;, last, once there were local interfaces to replace the GTB bits it was clinging to.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Easy things first, not because they matter most but because each one takes a bit of coupling out of the framework and makes the next one cheaper.&lt;/p&gt;&#10;&lt;p&gt;By the time you reach &lt;code&gt;chat&lt;/code&gt;, half of what made it a four has already been dismantled by the work in front of it. The other thing the scores bought was permission to &lt;em&gt;stop&lt;/em&gt;. Anything at five or below was excluded from consideration entirely, which sounds like a small administrative decision and isn&amp;rsquo;t. Without it, a scoring exercise turns into a to-do list with twenty-seven items on it and no way to argue against any of them.&lt;/p&gt;&#10;&lt;h2 id="what-it-didnt-tell-me"&gt;What it didn&amp;rsquo;t tell me&#10;&lt;/h2&gt;&lt;p&gt;The table is a good instrument, and it was wrong about several things in the direction I&amp;rsquo;d least expected: the &lt;em&gt;easy&lt;/em&gt; ones. &lt;code&gt;logger&lt;/code&gt; scored ten for ease. &lt;code&gt;forms&lt;/code&gt; scored ten for ease. Both sat in step two, both looked like a morning&amp;rsquo;s work each, and neither of them is a module today. One was deleted outright and the other is still sat in the framework where it started.&lt;/p&gt;&#10;&lt;p&gt;Being wrong about &lt;code&gt;chat&lt;/code&gt; would have been ordinary. Being wrong about the packages I&amp;rsquo;d already marked as trivial is the interesting failure, and a hundred and eight numbers later the column I&amp;rsquo;d been most confident about was the one that lied to me.&lt;/p&gt;</content:encoded></item><item><title>The packages that didn't want to leave</title><link>https://phpboyscout.uk/the-packages-that-didnt-want-to-leave/</link><pubDate>Fri, 02 Oct 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-packages-that-didnt-want-to-leave/</guid><category>go-tool-base</category><category>Pioneering</category><description>Everyone writes up the modules that came out. The decisions that mattered were the four packages I decided to leave exactly where they were.</description><content:encoded>&lt;p&gt;There are sixteen directories in go-tool-base&amp;rsquo;s &lt;code&gt;pkg/&lt;/code&gt; today, and that&amp;rsquo;s &lt;em&gt;after&lt;/em&gt; the extraction. I counted them twice, on a Sunday afternoon, because the first count didn&amp;rsquo;t seem right.&lt;/p&gt;&#10;&lt;h2 id="the-part-that-doesnt-get-written-up"&gt;The part that doesn&amp;rsquo;t get written up&#10;&lt;/h2&gt;&lt;p&gt;Decomposition stories are always told through what came out. Here&amp;rsquo;s the module, here&amp;rsquo;s the import path, here&amp;rsquo;s the tidy little &lt;code&gt;go.mod&lt;/code&gt; with three lines in it. Fine, but I&amp;rsquo;d &lt;a class="link" href="https://phpboyscout.uk/i-graded-every-package-for-how-badly-it-wants-to-leave/" &gt;scored twenty-seven packages&lt;/a&gt; before any of that started, and the scoring was the easy bit.&lt;/p&gt;&#10;&lt;p&gt;The hard calls were the ones where the answer came back &lt;em&gt;no&lt;/em&gt;. There were four of them, and no two of them had the same reason, which is the thing I took away from it: &amp;ldquo;extract or don&amp;rsquo;t&amp;rdquo; turned out to be nowhere near a big enough vocabulary. Ask a binary question and most packages eventually become modules, because &amp;ldquo;no&amp;rdquo; tends to look like you couldn&amp;rsquo;t be bothered.&lt;/p&gt;&#10;&lt;h2 id="delete-it-dont-extract-it"&gt;Delete it, don&amp;rsquo;t extract it&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;pkg/forms&lt;/code&gt; was the wizard layer, the prompts you get when you run &lt;code&gt;gtb generate project&lt;/code&gt; and it asks what you&amp;rsquo;d like it to build. It scored ten out of ten for ease of decoupling: trivial to lift out, no framework coupling worth the name, straight into wave two alongside the other easy wins. And then, before touching it, the obvious question (the one I should perhaps have asked first)&amp;hellip; does this still earn its keep at all?&lt;/p&gt;&#10;&lt;p&gt;It didn&amp;rsquo;t. &lt;a class="link" href="https://charm.land/huh" target="_blank" rel="noopener"&#10; &gt;huh&lt;/a&gt; had moved on to v2 and absorbed the entire purpose of the thing, so what &lt;code&gt;forms&lt;/code&gt; was doing in 2024 was papering over gaps that no longer existed, and I&amp;rsquo;d have been extracting a wrapper around a library that had learned to do the job itself.&lt;/p&gt;&#10;&lt;p&gt;So it went. Commit &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/8b5732c0" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;8b5732c0&lt;/code&gt;&lt;/a&gt; rewrites the wizards on native huh v2 &lt;em&gt;and&lt;/em&gt; deletes the package in one go, which is a very satisfying shape for a commit to have.&lt;/p&gt;&#10;&lt;p&gt;The most valuable extraction decision I made that week was an &lt;code&gt;rm -rf&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="keep-it-because-the-world-has-enough-of-these"&gt;Keep it, because the world has enough of these&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;logger&lt;/code&gt; also scored ten for ease, also went into wave two, and also never left. Completely different reason, and it&amp;rsquo;s the call I&amp;rsquo;m surest about of the four. I feel like there are too many loggers out there at the moment and adding another package to that pile becomes its own problem, this implementation is very specific to gtb, and all the packages I&amp;rsquo;d &lt;em&gt;already&lt;/em&gt; extracted use &lt;code&gt;slog&lt;/code&gt; as their intersection.&lt;/p&gt;&#10;&lt;p&gt;That second half is the bit that matters. If half a dozen extracted modules all talk to &lt;code&gt;slog&lt;/code&gt;, then the standard library is already the shared interface, and there is nothing left for a &lt;code&gt;go/logger&lt;/code&gt; module to &lt;em&gt;do&lt;/em&gt; except sit in the middle of a relationship that was working fine without it.&lt;/p&gt;&#10;&lt;p&gt;Extracting it wouldn&amp;rsquo;t have shared anything! It would have manufactured a dependency, on me, in every module that adopted it, in exchange for an interface Go already ships. The framework keeps its own logger because the framework has opinions about how a CLI should look when it talks. That&amp;rsquo;s a product decision, and I&amp;rsquo;m not sure a product decision is something you should hand to other people as a library.&lt;/p&gt;&#10;&lt;h2 id="keep-it-because-it-really-is-framework-shaped"&gt;Keep it, because it really is framework-shaped&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;telemetry&lt;/code&gt; is the opposite case, and it got the opposite treatment. I suspected it was too tangled to be worth extracting, but a suspicion is not a verdict, so it went off to be examined properly: dig into the design limitations, work out whether it even warrants extraction, don&amp;rsquo;t assume.&lt;/p&gt;&#10;&lt;p&gt;Verdict: staying, and definitely staying.&lt;/p&gt;&#10;&lt;p&gt;How I got there matters rather more than the answer did, though, because if I&amp;rsquo;d just trusted the hunch I&amp;rsquo;d have got the same result and learned nothing, and the next hunch would have been every bit as unexamined. The rubric exists so these calls aren&amp;rsquo;t vibes, including the ones where the vibe was right all along.&lt;/p&gt;&#10;&lt;h2 id="or-widen-it-which-i-didnt-see-coming"&gt;Or widen it, which I didn&amp;rsquo;t see coming&#10;&lt;/h2&gt;&lt;p&gt;The fourth one refused both options. go-tool-base carried a wide GitHub client, and a lot of that code was left over from an earlier incarnation of the framework, written while I was at a very GitHub-centric employer. Extracting it would have shipped one vendor&amp;rsquo;s API as though it were an abstraction; deleting it would have thrown away work that did something useful.&lt;/p&gt;&#10;&lt;p&gt;So it got &lt;em&gt;widened&lt;/em&gt; instead: the whole thing became the &lt;code&gt;go/forge&lt;/code&gt; contract with per-provider adapters underneath, and today there are four of them. That has its own story and I&amp;rsquo;ll write it up properly another time, because the interesting part isn&amp;rsquo;t the refactor, it&amp;rsquo;s carrying a previous job&amp;rsquo;s code around for years without noticing what shape it had left you in.&lt;/p&gt;&#10;&lt;h2 id="five-words-not-two"&gt;Five words, not two&#10;&lt;/h2&gt;&lt;p&gt;So here&amp;rsquo;s the vocabulary the programme actually needed.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Extract.&lt;/strong&gt; Real reuse value, and the coupling can come out.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Keep, too specific.&lt;/strong&gt; It&amp;rsquo;s framework-shaped. Someone else would have to bend it to fit.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Keep, too generic.&lt;/strong&gt; The ecosystem has this covered, or the standard library already is the interface.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Delete.&lt;/strong&gt; Something else grew into the job while you weren&amp;rsquo;t looking.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Widen.&lt;/strong&gt; It&amp;rsquo;s the right idea, dressed for one vendor.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Only the first is what people mean when they say &amp;ldquo;extract&amp;rdquo;. Two of the others leave the package where it was and neither is a shrug, one kills it outright, and one keeps the idea while throwing the implementation away. Five different answers, and a yes/no question can only ever reach two of them.&lt;/p&gt;&#10;&lt;h2 id="whats-left-is-what-it-is"&gt;What&amp;rsquo;s left is what it is&#10;&lt;/h2&gt;&lt;p&gt;After the waves, &lt;code&gt;pkg/&lt;/code&gt; holds sixteen things: the CLI wiring, props, setup, docs, the transports, the logger, the telemetry, version, chat. That&amp;rsquo;s not a leftovers pile so much as the answer to &amp;ldquo;what is go-tool-base, actually&amp;rdquo;, written out as a directory listing, and I couldn&amp;rsquo;t have told you it before I started. I suspect most frameworks have a list like this, and never find out what&amp;rsquo;s on it, because nothing ever forces the question.&lt;/p&gt;&#10;&lt;p&gt;I do think the whole exercise was worth it, and said so at the time in rather more exclamation marks than were strictly necessary, but the modules aren&amp;rsquo;t the thing I&amp;rsquo;d point at.&lt;/p&gt;&#10;&lt;p&gt;Sixteen directories, and now I know why each one is still there.&lt;/p&gt;</content:encoded></item><item><title>The extraction playbook only got real when chat left</title><link>https://phpboyscout.uk/the-extraction-playbook-only-got-real-when-chat-left/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-extraction-playbook-only-got-real-when-chat-left/</guid><category>go-tool-base</category><category>Pioneering</category><description>I wrote the process document before doing the hard one. Then the hard one went through it and rewrote a step.</description><content:encoded>&lt;p&gt;I wrote the playbook before I&amp;rsquo;d done the hard one, which, said out loud, sounds a bit daft.&lt;/p&gt;&#10;&lt;p&gt;It didn&amp;rsquo;t feel daft at the time (they never do), and I&amp;rsquo;d do it again.&lt;/p&gt;&#10;&lt;h2 id="why-there-was-a-playbook-at-all"&gt;Why there was a playbook at all&#10;&lt;/h2&gt;&lt;p&gt;Two things existed before it. &lt;code&gt;signing&lt;/code&gt; had already left, months earlier, back when extracting a module was a one-off rather than a programme. That gave me the mechanics: a framework-free module implementing a small contract, consumed back by the framework through adapters, with its own CI, its own docs site, its own dependency-footprint guard. It worked. The spec later calls it the &lt;em&gt;validation dry-run&lt;/em&gt;, which is generous&amp;hellip; at the time it was just &amp;ldquo;the signing thing&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;Then came &lt;a class="link" href="https://phpboyscout.uk/i-graded-every-package-for-how-badly-it-wants-to-leave/" &gt;the report&lt;/a&gt;, which said &lt;em&gt;which&lt;/em&gt; packages should go and in what order. Neither of those tells you &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;&#10;&lt;p&gt;And I could see what was coming: a dozen extractions, each one re-deciding the same handful of questions. What do we call it? Where do the docs live? What does the repo need before it counts as real? When does the framework cut over? Get that wrong and you don&amp;rsquo;t get a toolkit, you get a dozen modules that each look like whichever week they were built in.&lt;/p&gt;&#10;&lt;p&gt;So on the 12th of July I wrote it down, and the spec is blunt about its own purpose: codify the repeatable process and conventions so every subsequent extraction is consistent and recognisable, rather than re-decided each time. A meta-spec, if you like, whose job is to stop other specs arguing.&lt;/p&gt;&#10;&lt;h2 id="eight-steps-and-a-lot-of-opinions"&gt;Eight steps and a lot of opinions&#10;&lt;/h2&gt;&lt;p&gt;The procedure came out at eight steps, and they&amp;rsquo;re deliberately boring:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Bootstrap the repo, CI green on an empty scaffold.&lt;/li&gt;&#10;&lt;li&gt;Move the code, and sever anything still reaching back into the framework.&lt;/li&gt;&#10;&lt;li&gt;Tests and guards carried over, coverage held above 90%.&lt;/li&gt;&#10;&lt;li&gt;Diátaxis docs and a Pages microsite.&lt;/li&gt;&#10;&lt;li&gt;Cut &lt;code&gt;v0.1.0&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Framework cut-over in a single MR: add the dependency, &lt;strong&gt;delete the in-tree package&lt;/strong&gt;.&lt;/li&gt;&#10;&lt;li&gt;Cross-reference the new microsite from the framework&amp;rsquo;s docs.&lt;/li&gt;&#10;&lt;li&gt;Downstream consumers repoint.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Alongside that, the naming: a subgroup rather than a prefix, so it&amp;rsquo;s &lt;code&gt;go/chat&lt;/code&gt; and not &lt;code&gt;gtb-chat&lt;/code&gt;. Bare component names, because recognition comes from the subgroup rather than from stuttering the framework&amp;rsquo;s name into every import. Docs subdomain mirroring the module path, so &lt;code&gt;go/chat&lt;/code&gt; becomes &lt;code&gt;chat.go.phpboyscout.uk&lt;/code&gt; and there&amp;rsquo;s nothing to remember. All very tidy. And all written by a man who had extracted one thing, and an easy one at that.&lt;/p&gt;&#10;&lt;h2 id="and-then-chat"&gt;And then chat&#10;&lt;/h2&gt;&lt;p&gt;The playbook names &lt;code&gt;pkg/chat&lt;/code&gt; as the first greenfield extraction, in its own words. &lt;code&gt;chat&lt;/code&gt; is also the package that scored 10 for downstream value and 4 for ease of decoupling, the widest gap in the whole table, and the reason the running order put it last rather than first.&lt;/p&gt;&#10;&lt;p&gt;So the first real test of the process was the worst case in the building, not quite by accident, but not by design either, just where the two documents happened to point at each other. And it went through all eight steps: &lt;code&gt;go/chat&lt;/code&gt; exists, it&amp;rsquo;s on its own release line, and its &lt;code&gt;go.mod&lt;/code&gt; mentions &lt;a class="link" href="https://gtb.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;go-tool-base&lt;/a&gt; zero times! That was the whole point, and very much the hard part.&lt;/p&gt;&#10;&lt;p&gt;And then it broke step six.&lt;/p&gt;&#10;&lt;h2 id="the-step-that-didnt-survive"&gt;The step that didn&amp;rsquo;t survive&#10;&lt;/h2&gt;&lt;p&gt;Step six says: add the module dependency, delete the in-tree package. Open go-tool-base today and &lt;code&gt;pkg/chat&lt;/code&gt; is still there.&lt;/p&gt;&#10;&lt;p&gt;Ten files of it: &lt;code&gt;config_adapter.go&lt;/code&gt;, &lt;code&gt;adapter_helpers.go&lt;/code&gt;, &lt;code&gt;providers.go&lt;/code&gt;, a doc comment and some constants, each of them importing &lt;code&gt;gitlab.com/phpboyscout/go/chat&lt;/code&gt; and doing something to it. I don&amp;rsquo;t think that&amp;rsquo;s a failed cut-over so much as a category the playbook didn&amp;rsquo;t have.&lt;/p&gt;&#10;&lt;p&gt;Because a framework that consumes an extracted module still needs somewhere to do the paperwork. &lt;code&gt;go/chat&lt;/code&gt; takes typed settings and knows nothing about config containers, hot reload or the environment-prefix rules, and that&amp;rsquo;s why it&amp;rsquo;s reusable, but &lt;em&gt;something&lt;/em&gt; has to turn the framework&amp;rsquo;s resolved configuration into those typed settings and that something has to live on the framework&amp;rsquo;s side of the line. It&amp;rsquo;s the &lt;a class="link" href="https://phpboyscout.uk/config-is-a-border-not-a-chain/" &gt;border pattern&lt;/a&gt; again, arriving from a completely different direction and refusing to be optional.&lt;/p&gt;&#10;&lt;p&gt;So what step six actually means, now that something hard has been through it, is closer to &lt;em&gt;delete the implementation, keep the border post&lt;/em&gt;. The name on the directory stays the same, and what&amp;rsquo;s inside it changes from a few thousand lines of chat client to a few hundred lines of translation.&lt;/p&gt;&#10;&lt;h2 id="id-write-it-that-early-again"&gt;I&amp;rsquo;d write it that early again&#10;&lt;/h2&gt;&lt;p&gt;The playbook was written too early, and I&amp;rsquo;d do it again tomorrow. Steps one to five held perfectly: naming, repo scaffold, coverage bar, docs site, version cut. A dozen modules later those are the reason the toolkit looks like one thing rather than twelve, and not one of them has been re-argued since.&lt;/p&gt;&#10;&lt;p&gt;What the hard case changed was one step, and it changed it in a way I could never have reasoned my way to in advance. I&amp;rsquo;d have written &amp;ldquo;delete the package&amp;rdquo; every time, because that&amp;rsquo;s what happened on &lt;code&gt;signing&lt;/code&gt;&amp;hellip; and &lt;code&gt;signing&lt;/code&gt; really did have nothing left to translate.&lt;/p&gt;&#10;&lt;p&gt;That isn&amp;rsquo;t an argument for waiting until you&amp;rsquo;ve done the hard one, because then you never write it at all and every extraction is a fresh argument. It&amp;rsquo;s more that a process document is a hypothesis. It looks like a rule, it&amp;rsquo;s formatted like a rule, people follow it like a rule, but until something difficult has run all the way through it you&amp;rsquo;re reading a description of the easy case with the confidence of a standard.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;chat&lt;/code&gt; was the hard one, and it went first through the door marked &lt;em&gt;first greenfield extraction&lt;/em&gt;, and it came out the other side having edited the instructions on its way past.&lt;/p&gt;</content:encoded></item><item><title>Config is a border, not a chain</title><link>https://phpboyscout.uk/config-is-a-border-not-a-chain/</link><pubDate>Tue, 08 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/config-is-a-border-not-a-chain/</guid><category>go-tool-base</category><category>Pioneering</category><description>I built a boundary so I wouldn't have to move my config package. Then I moved it anyway, and the boundary is the only reason that didn't hurt.</description><content:encoded>&lt;p&gt;go-tool-base was meant to be an all-in-one framework, and for a good while that was exactly right. Then I started wanting bits of it on their own. Signing went first, off into its own module, and that went well enough that I lined up the chat package to follow.&lt;/p&gt;&#10;&lt;p&gt;So I costed it. To let a package go off and find an API token by itself, I&amp;rsquo;d have to take Viper along. And afero. And fsnotify, pflag binding, and every last scrap of environment-prefix behaviour we&amp;rsquo;d accumulated over the years.&lt;/p&gt;&#10;&lt;p&gt;All that, so a package could learn a string.&lt;/p&gt;&#10;&lt;h2 id="the-helpful-thing-had-turned-into-the-fence"&gt;The helpful thing had turned into the fence&#10;&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d done this dance once already with logging. A package that reaches for the framework&amp;rsquo;s logger is a package that can&amp;rsquo;t leave, and the answer was to hand it an interface instead of a dependency. That one&amp;rsquo;s written up in &lt;a class="link" href="https://phpboyscout.uk/a-logging-interface-that-doesnt-leak-its-backend/" &gt;a logging interface that doesn&amp;rsquo;t leak its backend&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Config was the same trap in a much better disguise. Worse, actually, because config is genuinely useful. Everything wants some. Our container was the one thing in the building that knew how a value had really been resolved: which file it came from, whether an env var had beaten it, what the default was if nobody said anything at all. That&amp;rsquo;s a real service. The packages were right to want it.&lt;/p&gt;&#10;&lt;p&gt;The trouble was what wanting it looked like once you opened the file. &lt;code&gt;pkg/chat&lt;/code&gt; reached into that container in three separate places. Picking a provider. Working out a fallback. Resolving a credential. Three small, sensible, entirely defensible lines. And between them, a chain bolting the package to the framework&amp;rsquo;s floor.&lt;/p&gt;&#10;&lt;h2 id="the-obvious-answer-which-was-wrong"&gt;The obvious answer, which was wrong&#10;&lt;/h2&gt;&lt;p&gt;Extract config first. Do it before anything else, because it&amp;rsquo;s the blocker.&lt;/p&gt;&#10;&lt;p&gt;I said that out loud, quite confidently, and it has a lovely logic to it. If the thing in the way is config, move config.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s wrong, and it took me an embarrassingly long time to see why. Our config isn&amp;rsquo;t a data structure. It&amp;rsquo;s a set of &lt;em&gt;promises about resolution order&lt;/em&gt;. File, then environment, then flags, with a prefix convention and hot reload sitting on top of the lot. Pull that out wholesale and you either drag every one of those promises into every consumer, or you break them somewhere nobody notices until it&amp;rsquo;s in production and somebody&amp;rsquo;s &lt;code&gt;PHPBS_&lt;/code&gt; prefix has quietly stopped winning.&lt;/p&gt;&#10;&lt;p&gt;And I had one line I wasn&amp;rsquo;t crossing. Whatever I did here, a user&amp;rsquo;s config file had to carry on working exactly the way it did yesterday. Not mostly. Exactly.&lt;/p&gt;&#10;&lt;h2 id="paperwork-not-a-tow-rope"&gt;Paperwork, not a tow rope&#10;&lt;/h2&gt;&lt;p&gt;So it went the other way round.&lt;/p&gt;&#10;&lt;p&gt;Instead of packages reaching into the framework, each package writes down what it needs as a plain typed struct of its own, and the framework fills it in at the boundary. In Go that&amp;rsquo;s just unmarshalling: take a lump of resolved config, pour it into a struct the package defined for itself.&lt;/p&gt;&#10;&lt;p&gt;Think customs paperwork rather than a tow rope. The package says &amp;ldquo;endpoint, timeout, token, please&amp;rdquo;. The framework, being the only thing that actually knows where any of those came from, fills in the form and hands it over. Nothing crosses the border except values. Constructors ended up in two flavours. A plain one that takes typed settings and knows nothing whatsoever about the framework, and an adapter next to it that takes the config container, does the paperwork, and calls the plain one.&lt;/p&gt;&#10;&lt;p&gt;Reusable as the default, framework compatibility as the special case. Which is the right way round, and I got there via one thoroughly wrong turn&amp;hellip; I had it backwards at first, with the typed version wearing the awkward name, and had to go back and revert the lot.&lt;/p&gt;&#10;&lt;p&gt;The thing that actually made it stick wasn&amp;rsquo;t a code rule at all. It was a testing rule. &lt;strong&gt;Package tests use typed settings. Only the adapter&amp;rsquo;s own tests are allowed to build a config container.&lt;/strong&gt; Sounds like bookkeeping, and it&amp;rsquo;s the most useful line in the whole design, because the moment an ordinary test reaches for that container you&amp;rsquo;ve found a leak.&lt;/p&gt;&#10;&lt;h2 id="then-hot-reload-nearly-did-for-it"&gt;Then hot reload nearly did for it&#10;&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the wall I walked into.&lt;/p&gt;&#10;&lt;p&gt;A package is holding a struct of values that got copied at construction. Somebody edits their config file while the thing is running. That package is now sat there holding a polite lie. The framework knows the value moved; the struct hasn&amp;rsquo;t the faintest idea. So I built observers for it. The adapter notices a reload, rehydrates its typed settings, and the package behind the border ends up holding the new values instead of the old ones.&lt;/p&gt;&#10;&lt;p&gt;The bit that mattered wasn&amp;rsquo;t the mechanism though, it was making it compulsory. I&amp;rsquo;d written it up first as guidance, which is a polite way of saying optional, and optional is exactly the sort of thing that gets skipped on a Friday afternoon and bites you six months later when nobody remembers the rule existed. So it got tightened into an actual rule: long-lived adapters use the common helper, and the helper does the lot for you.&lt;/p&gt;&#10;&lt;p&gt;Which is &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/blob/3a3c697fd4bf200c5748581a6aa1631397624dff/observed_section.go#L268" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;ObserveSection&lt;/code&gt;&lt;/a&gt;. Initial unmarshal, register an observer, then publish a fresh snapshot whenever the typed section genuinely changes. Not when any old key moves, which would have every package in the building waking up because somebody fixed a typo in a log level. When &lt;em&gt;that struct&lt;/em&gt; differs from the last one it published.&lt;/p&gt;&#10;&lt;p&gt;There&amp;rsquo;s a &lt;code&gt;SectionChange&lt;/code&gt; carrying the before and after too, so a package can see what actually moved rather than just being told something did. The reload machinery itself is &lt;a class="link" href="https://phpboyscout.uk/reloading-config-without-a-restart/" &gt;reloading config without a restart&lt;/a&gt;; this is the bit where the packages behind the border finally get told about it.&lt;/p&gt;&#10;&lt;h2 id="and-then-i-moved-it-anyway"&gt;And then I moved it anyway&#10;&lt;/h2&gt;&lt;p&gt;Now. Everything above argues that config &lt;em&gt;stays put&lt;/em&gt;. Crown jewels, holds the promises, packages get paperwork and the container never leaves home. I believed that. I wrote it down. I based a fortnight of refactoring on it.&lt;/p&gt;&#10;&lt;p&gt;Within a fortnight I&amp;rsquo;d extracted it.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;go/config&lt;/code&gt; went off and became its own module, and then I did something considerably ruder than merely moving it&amp;hellip; I tore Viper out and rebuilt the thing around a Store with real provenance on every value. &lt;code&gt;spf13/viper&lt;/code&gt; doesn&amp;rsquo;t appear in that &lt;code&gt;go.mod&lt;/code&gt; at all now. &lt;code&gt;pkg/config&lt;/code&gt; doesn&amp;rsquo;t exist in go-tool-base. The commit that finished the job is called, with no ceremony whatsoever, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/bd0253a1" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;refactor(config): consume the extracted go/config module&lt;/code&gt;&lt;/a&gt;, and these days the framework depends on &lt;code&gt;go/config v0.13.3&lt;/code&gt; like any other library off the shelf.&lt;/p&gt;&#10;&lt;p&gt;So by the letter of it I was wrong. The premise of the whole exercise was that config wouldn&amp;rsquo;t move, and it moved.&lt;/p&gt;&#10;&lt;p&gt;Except nothing noticed! Not one package. &lt;code&gt;chat&lt;/code&gt;, &lt;code&gt;tls&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;grpc&lt;/code&gt;, the gateway, the telemetry core, every last release provider&amp;hellip; none of them changed, because none of them was reaching through the border to begin with. They were sat there holding their own structs, filled in by an adapter, and swapping out the entire machine behind that adapter turned out to be somebody else&amp;rsquo;s problem entirely. Mine, obviously. But only mine, and only on one side of the line.&lt;/p&gt;&#10;&lt;h2 id="what-the-border-actually-bought"&gt;What the border actually bought&#10;&lt;/h2&gt;&lt;p&gt;I built it so I wouldn&amp;rsquo;t have to move config. Then I moved config, and the border is the only reason that didn&amp;rsquo;t hurt.&lt;/p&gt;&#10;&lt;p&gt;Which isn&amp;rsquo;t the lesson I set out to learn, and it&amp;rsquo;s a better one. I thought I was buying extractability for the packages. What I actually bought was the freedom to rip up my own side of the line without asking anyone&amp;rsquo;s permission, because the only thing I&amp;rsquo;d promised them was a &lt;em&gt;shape&lt;/em&gt;. Where the values came from, how they layered, what beat what&amp;hellip; all of that stayed mine. And in the end I changed the lot of it. Viper&amp;rsquo;s gone, and &lt;code&gt;chat&lt;/code&gt; still doesn&amp;rsquo;t know.&lt;/p&gt;</content:encoded></item><item><title>The writer I added, and the Viper I deleted by accident</title><link>https://phpboyscout.uk/the-writer-i-added-and-the-viper-i-deleted/</link><pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-writer-i-added-and-the-viper-i-deleted/</guid><category>go-config</category><category>Pioneering</category><description>I set out to add a config writer and keep Viper. I ended up keeping the writer and deleting Viper, and I didn't notice until it was already gone.</description><content:encoded>&lt;p&gt;&lt;a class="link" href="https://keryx.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;keryx&lt;/a&gt; has a studio, and the studio saves as you work. Change a setting, it goes to disk, the app picks it up and carries on. Nothing exotic about that. Every desktop app written in the last thirty years does the same thing.&lt;/p&gt;&#10;&lt;p&gt;So keryx put in a request against &lt;code&gt;go/config&lt;/code&gt;, which had only just been extracted into its own module and was feeling rather pleased with itself. The ask was boring to the point of being polite: when you write the file back out, keep the comments the human wrote, keep the key order, keep the formatting, and only touch the file that actually owns the key I changed. Nobody sensible rewrites a config library over comment preservation.&lt;/p&gt;&#10;&lt;h2 id="what-save-your-config-was-really-doing"&gt;What &amp;ldquo;save your config&amp;rdquo; was really doing&#10;&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the problem with a config library that merges layers for you. By the time it can answer a question, it has already thrown away the one thing you&amp;rsquo;d need to write an answer back.&lt;/p&gt;&#10;&lt;p&gt;Viper resolves your configuration down into a single view. Defaults at the bottom, then the file, then environment variables, then flags, each one covering the last. For reading that&amp;rsquo;s exactly right. You ask for &lt;code&gt;server.port&lt;/code&gt;, you get the winner, and nobody has to care where it came from. Then you ask it to save. And it writes out the view. Not your file. The view.&lt;/p&gt;&#10;&lt;p&gt;Two things fall out of that, and the second one made me put my coffee down. Every comment the author wrote is gone, because comments were never in the merged view to begin with. And every value that came from somewhere else is now sat &lt;em&gt;in your file&lt;/em&gt;, promoted to a permanent fact on disk. Defaults you never chose. Flags from one invocation last Tuesday. And environment variables.&lt;/p&gt;&#10;&lt;p&gt;So a user exports an API token, runs your app, your app helpfully saves its configuration&amp;hellip; and you have written their token into a file in plain text. Nobody asked for that. Nothing warned them. The save button did it. I&amp;rsquo;ve banged on before about how &lt;a class="link" href="https://phpboyscout.uk/everyone-reads-your-config/" &gt;everyone reads your config&lt;/a&gt; and how &lt;a class="link" href="https://phpboyscout.uk/a-configurable-ai-endpoint-is-an-attack-surface/" &gt;a configurable AI endpoint is an attack surface&lt;/a&gt;, and this is the same beast wandering in through a door I&amp;rsquo;d never thought to check.&lt;/p&gt;&#10;&lt;p&gt;And you can&amp;rsquo;t wrap your way out of it. The thing you need, which is &lt;em&gt;which layer did this value come from&lt;/em&gt;, gets discarded during the merge, long before anybody contemplates a write. It isn&amp;rsquo;t hidden away somewhere awkward. It doesn&amp;rsquo;t exist.&lt;/p&gt;&#10;&lt;h2 id="the-question-i-didnt-want-to-ask"&gt;The question I didn&amp;rsquo;t want to ask&#10;&lt;/h2&gt;&lt;p&gt;I went into this fond of Viper and fully intending to keep it. It had done us proud for years and I had no appetite whatsoever for a rewrite in the middle of an extraction. Find the right place to hook in, bolt a writer alongside, done.&lt;/p&gt;&#10;&lt;p&gt;An hour in, I asked myself the question that ended that plan. Was Viper actually the problem here? Were we tying ourselves in knots honouring its contract, when what we fundamentally needed was a thing it was never going to give us?&lt;/p&gt;&#10;&lt;p&gt;Then, because I didn&amp;rsquo;t remotely trust myself to answer that fairly, I rigged the test in Viper&amp;rsquo;s favour. Greenfield exercise. Pretend there&amp;rsquo;s no existing code and no incumbent, write down everything this module needs from a config component, and only &lt;em&gt;then&lt;/em&gt; go back and count how many of them Viper covers. I expected about 70%. More to the point, I &lt;em&gt;wanted&lt;/em&gt; about 70%, because 70% is a lovely comfortable number that lets you keep what you&amp;rsquo;ve got and paper over the rest.&lt;/p&gt;&#10;&lt;p&gt;It came out at 20%.&lt;/p&gt;&#10;&lt;p&gt;That needs a caveat or it&amp;rsquo;s just unfair. Viper&amp;rsquo;s own docs reckon it does about 95% of what most projects need, and I&amp;rsquo;d say that&amp;rsquo;s honest. Both numbers are true at once. It&amp;rsquo;s excellent at the common case, and this module had walked itself somewhere distinctly uncommon by deciding that writing files properly was a first-class feature rather than a hack bolted on the end.&lt;/p&gt;&#10;&lt;h2 id="record-it-on-the-way-through"&gt;Record it on the way through&#10;&lt;/h2&gt;&lt;p&gt;The fix falls straight out of the diagnosis. If the problem is that provenance gets binned during the merge, then record it &lt;em&gt;while&lt;/em&gt; you merge.&lt;/p&gt;&#10;&lt;p&gt;So &lt;code&gt;go/config&lt;/code&gt; grew a &lt;strong&gt;Store&lt;/strong&gt; that owns configuration I/O end to end. It layers the sources itself, and as it goes it keeps track of which layer every value came from. Reading is &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/blob/126626db7522a2975ef7c1e2f108a9a5fcf0e19d/view.go#L35" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;View()&lt;/code&gt;&lt;/a&gt;, which hands back resolved answers exactly as before. Writing is &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/blob/126626db7522a2975ef7c1e2f108a9a5fcf0e19d/store.go#L734" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;Apply()&lt;/code&gt;&lt;/a&gt;, which takes a set of changes and, crucially, can work out &lt;em&gt;where each one belongs&lt;/em&gt;.&lt;/p&gt;&#10;&lt;p&gt;Change a key that came from the project file, and the project file gets edited. A value that arrived from the environment was never yours to persist, so it doesn&amp;rsquo;t get written anywhere at all.&lt;/p&gt;&#10;&lt;p&gt;The editing goes through a document layer that rewrites bytes in place rather than re-encoding, which is what saves the comments and the ordering. Where a change spans several files they commit one at a time, each writing to a temp file and &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/blob/126626db7522a2975ef7c1e2f108a9a5fcf0e19d/write.go#L178" target="_blank" rel="noopener"&#10; &gt;renaming it over the target&lt;/a&gt;, with a rollback if a later one falls over. Sequential and boring, deliberately. There was a fan-out design floating about at one point that would have made it concurrent and exciting, and I&amp;rsquo;m glad it never shipped.&lt;/p&gt;&#10;&lt;p&gt;Then, somewhere near the end of all that, I looked at what Viper was actually still doing.&lt;/p&gt;&#10;&lt;p&gt;Nothing. Not a thing. Everything left had already been taken over: the Store did layering, resolution and precedence, the codecs did formats, the document layer did writes. Whatever remained was being handled directly, and rather better, by the two libraries Viper wraps. It was a box on the diagram with no arrows coming out of it. I sat there for a few seconds going, hang on&amp;hellip; Viper&amp;rsquo;s gone? Nobody decided to remove it. It just ran out of reasons to be in the building.&lt;/p&gt;&#10;&lt;h2 id="i-never-actually-decided-to"&gt;I never actually decided to&#10;&lt;/h2&gt;&lt;p&gt;I opened that session having explicitly ruled out replacing Viper. First path considered, first one rejected, on the entirely sensible grounds that it was wildly disproportionate to a request about preserving comments. I closed it having replaced Viper, in commit &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/commit/1f5129cbd52d2b9904c3c98ca449a79793b4bcd8" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;1f5129c&lt;/code&gt;&lt;/a&gt;, without ever actually deciding to. It wasn&amp;rsquo;t a decision. It was a consequence. Solve the provenance problem properly and the dependency stops earning its keep all by itself.&lt;/p&gt;&#10;&lt;p&gt;For the record, Viper went and its neighbours stayed. &lt;code&gt;afero&lt;/code&gt;, &lt;code&gt;cast&lt;/code&gt;, &lt;code&gt;pflag&lt;/code&gt; and &lt;code&gt;mapstructure&lt;/code&gt; are all still sat in that &lt;code&gt;go.mod&lt;/code&gt; doing jobs they&amp;rsquo;re good at. This was not a purge, and anyone who tells you they removed a whole ecosystem in an afternoon is describing their &lt;code&gt;go.mod&lt;/code&gt;, not their code.&lt;/p&gt;&#10;&lt;h2 id="the-bit-that-still-nags"&gt;The bit that still nags&#10;&lt;/h2&gt;&lt;p&gt;The rewrite was the interesting work and the reversal is the better story, but neither is the thing I keep coming back to.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s that the real bug was never &amp;ldquo;the writer doesn&amp;rsquo;t preserve comments&amp;rdquo;. It was &amp;ldquo;saving your config can write your secrets to disk&amp;rdquo;, and it had been sat there the whole time, in a library I&amp;rsquo;d used happily for years, down a code path I&amp;rsquo;d simply never had cause to walk. It took a feature request from an unrelated project, about comment preservation of all things, to march me into it. I only found it because keryx wanted its settings file to stay tidy.&lt;/p&gt;</content:encoded></item><item><title>A format per module, not a fatter config core</title><link>https://phpboyscout.uk/a-format-per-module-not-a-fatter-config-core/</link><pubDate>Mon, 14 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/a-format-per-module-not-a-fatter-config-core/</guid><category>go-config</category><category>Pioneering</category><description>Viper reads a dozen extensions. Mine reads one, and the other formats live somewhere else entirely. Here's what that bought, measured rather than asserted.</description><content:encoded>&lt;p&gt;Rewriting &lt;code&gt;go/config&lt;/code&gt; around a Store fixed the thing I&amp;rsquo;d set out to fix, and handed me a much more obvious problem on the way out the door (it was about ten at night, and I&amp;rsquo;d been feeling rather pleased with myself). The new one read YAML.&lt;/p&gt;&#10;&lt;p&gt;Only YAML.&lt;/p&gt;&#10;&lt;p&gt;Viper, which I&amp;rsquo;d just &lt;a class="link" href="https://phpboyscout.uk/the-writer-i-added-and-the-viper-i-deleted/" &gt;deleted by accident&lt;/a&gt;, reads a dozen file extensions. So the objection writes itself before anyone has to be polite about it: a team with a &lt;code&gt;config.json&lt;/code&gt; can&amp;rsquo;t adopt this at all, and telling them to convert their config file to suit my library isn&amp;rsquo;t a serious answer to anything.&lt;/p&gt;&#10;&lt;h2 id="i-wasnt-putting-five-parsers-in-the-core"&gt;I wasn&amp;rsquo;t putting five parsers in the core&#10;&lt;/h2&gt;&lt;p&gt;The obvious fix is five more parsers in the core. Most config libraries in most languages have stood about here, and most of them took that road.&lt;/p&gt;&#10;&lt;p&gt;I didn&amp;rsquo;t fancy it, and the reason was already sat in the estate. Two other modules had solved the same shape of problem&amp;hellip; &lt;code&gt;chat&lt;/code&gt; has a sibling module per AI provider, &lt;code&gt;forge&lt;/code&gt; has one per git host. So the question became how to support the other formats &lt;em&gt;without bloating the core&lt;/em&gt;. Adapter projects, one per file type, the way chat and forge already do it. YAML stays the default, and if you want XML you import the XML one and say so.&lt;/p&gt;&#10;&lt;p&gt;And then the naming call, which matters a good deal more than it looks. One module per file type, named for it. &lt;code&gt;config-json&lt;/code&gt;, &lt;code&gt;config-xml&lt;/code&gt;, &lt;code&gt;config-toml&lt;/code&gt;. Specifically, and only, to keep the dependency footprint lean.&lt;/p&gt;&#10;&lt;h2 id="why-a-module-and-not-a-package"&gt;Why a module and not a package&#10;&lt;/h2&gt;&lt;p&gt;This bit is peculiar to Go and it catches people out, so it&amp;rsquo;s worth a plain-English paragraph. Go is good about code. Import a package you never call and the linker drops it; your binary doesn&amp;rsquo;t carry it. So on the face of it, one big &lt;code&gt;config-formats&lt;/code&gt; package with all the parsers in costs a JSON user nothing at runtime.&lt;/p&gt;&#10;&lt;p&gt;Except dependencies aren&amp;rsquo;t worked out per package. They&amp;rsquo;re worked out per module. One &lt;code&gt;go.mod&lt;/code&gt;, one &lt;code&gt;go.sum&lt;/code&gt;, listing what the &lt;em&gt;whole module&lt;/em&gt; needs. Put five parsers in one module and everybody who imports it inherits all five parsers&amp;rsquo; requirements, whether they touch them or not. Their &lt;code&gt;go.sum&lt;/code&gt; grows. Their vulnerability scanner has more to say for itself. Their audit has more to read. The binary&amp;rsquo;s fine and everything around the binary is a bit worse. One module per format is the shape where the graph tells the truth, or at least the only one I could find.&lt;/p&gt;&#10;&lt;p&gt;There&amp;rsquo;s a second benefit I didn&amp;rsquo;t plan and now rate higher than the first: a format either has a module or it hasn&amp;rsquo;t. There&amp;rsquo;s no list of supported formats sat somewhere, slowly drifting out of step with reality.&lt;/p&gt;&#10;&lt;p&gt;I found the next bit while I was measuring, and it made me laugh.&lt;/p&gt;&#10;&lt;h2 id="what-viper-advertises-and-what-viper-ships"&gt;What Viper advertises, and what Viper ships&#10;&lt;/h2&gt;&lt;p&gt;Viper v1.21.0 exports the list of extensions it supports. Twelve of them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;SupportedExts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;json&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;toml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;yaml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;yml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;properties&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;props&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;prop&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;hcl&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tfvars&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;dotenv&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;env&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;ini&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Its &lt;code&gt;internal/encoding&lt;/code&gt; directory, where the codecs actually live, contains four. &lt;code&gt;dotenv&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;toml&lt;/code&gt;, &lt;code&gt;yaml&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;HCL is on that list. So are tfvars, INI and Java properties. Point Viper at a &lt;code&gt;.hcl&lt;/code&gt; file and it&amp;rsquo;ll happily accept the extension, get as far as wanting a decoder, and not find one. Now, that&amp;rsquo;s a maintenance artefact rather than anything underhand, and those formats were almost certainly real once. But it is the failure mode that &amp;ldquo;a format either has a module or it hasn&amp;rsquo;t&amp;rdquo; makes structurally impossible, and I only spotted it because I&amp;rsquo;d gone looking for a number to stick in a spec.&lt;/p&gt;&#10;&lt;h2 id="right-some-actual-numbers"&gt;Right, some actual numbers&#10;&lt;/h2&gt;&lt;p&gt;Assertions about dependency weight are cheap, so here are some real ones. All the figures come from the same probe: an empty module that imports exactly one thing, then &lt;code&gt;go list&lt;/code&gt;.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;What you import&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Modules&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Non-stdlib packages&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;spf13/viper&lt;/code&gt; v1.21.0&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;26&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;53&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;go/config&lt;/code&gt; core (YAML)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;28&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;23&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;core + &lt;code&gt;config-iofs&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;29&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;24&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;core + &lt;code&gt;config-billy&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;35&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;26&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;core + &lt;code&gt;config-json&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;33&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;28&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;core + &lt;code&gt;config-gcp-gcs&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;249&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;478&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Two things in there worth saying. The core pulls fewer than half the packages Viper does, while doing considerably more. That&amp;rsquo;s the result I was after. But it pulls &lt;em&gt;more modules&lt;/em&gt; than Viper, because the estate is deliberately built out of small ones. Measure the thing I optimised for and I win comfortably; measure module count and I lose a bit. Both numbers are real, so both go in the table.&lt;/p&gt;&#10;&lt;p&gt;The other is that bottom row. Reading config out of Google Cloud Storage costs 455 packages more than the core on its own, and there&amp;rsquo;s no clever trick that makes that cheaper, because most of it is Google&amp;rsquo;s client libraries and they are what they are. And that&amp;rsquo;s rather the point: the only people paying that are the people who asked for it. The spread from &lt;code&gt;config-iofs&lt;/code&gt; adding a single package to &lt;code&gt;config-gcp-gcs&lt;/code&gt; adding 455 is the whole argument, sat in one table.&lt;/p&gt;&#10;&lt;p&gt;One nuance, which I couldn&amp;rsquo;t put into words for a while. Early on I waved the dependency question away for myself, because keeping libraries current is a normal cost of maintaining anything and Renovate does most of the legwork. Still true. The footprint argument was never about the dependencies &lt;em&gt;I&lt;/em&gt; maintain. It&amp;rsquo;s about the ones I impose on someone else, who may never open that parser as long as they live.&lt;/p&gt;&#10;&lt;h2 id="two-things-i-got-wrong-left-on-the-record"&gt;Two things I got wrong, left on the record&#10;&lt;/h2&gt;&lt;p&gt;The specs carry their own reversals, deliberately unedited, and both are about writers. I&amp;rsquo;d assumed TOML would need a whole bespoke document module to edit files without mangling them, on measured evidence that no Go library round-trips TOML cleanly. That went down as a decision. Then it collapsed: &lt;code&gt;go-toml/v2/unstable&lt;/code&gt; exposes each node&amp;rsquo;s source byte range, which turns a TOML edit into a targeted byte replacement of about two hundred lines, inline. The module I&amp;rsquo;d specced never got built.&lt;/p&gt;&#10;&lt;p&gt;HCL was the same mistake pointing the other way. I had it down as the &lt;em&gt;hard&lt;/em&gt; writer, and it turned out to be the easy one, because HashiCorp already ship &lt;code&gt;hclwrite&lt;/code&gt; and it does structure-preserving edits properly. &lt;code&gt;config-hcl&lt;/code&gt; shipped reading and writing in one pass. The spec puts it plainly:&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;inverts the assumption in the first draft of D11, which treated writing HCL as the hard part. It is not; the hard part was always the semantics.&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;&lt;p&gt;Both mistakes had the same cause. I&amp;rsquo;d been thinking about the &lt;em&gt;editor&lt;/em&gt; as the difficult component, when the parser is the reusable primitive and the editor is thin work on top of it. Get the primitive right and the writer is a couple of hundred lines.&lt;/p&gt;&#10;&lt;p&gt;The hard part, as the spec says, was semantics. &lt;code&gt;config-hcl&lt;/code&gt; refuses to evaluate anything at all: &lt;code&gt;var.*&lt;/code&gt;, &lt;code&gt;${local.x}&lt;/code&gt;, functions, &lt;code&gt;include&lt;/code&gt;, all rejected at load and by name rather than resolved. HCL-as-configuration, explicitly not Terraform. Silently resolving half a Terraform file and handing back a plausible-looking answer is far worse than refusing the thing outright.&lt;/p&gt;&#10;&lt;h2 id="the-format-i-threw-out"&gt;The format I threw out&#10;&lt;/h2&gt;&lt;p&gt;HOCON didn&amp;rsquo;t make it, and the reason is the best argument for the whole design. There&amp;rsquo;s essentially one usable HOCON parser in Go. Reading it, it hardcodes environment-variable fallback, so a probe with &lt;code&gt;${LEAKED_SECRET}&lt;/code&gt; in a config file resolved it straight out of the process environment. That&amp;rsquo;s the very behaviour the core has a rule against, for the reasons in the last post about save-your-config writing your tokens to disk.&lt;/p&gt;&#10;&lt;p&gt;So the choice was ship a format whose only implementation defeats a core guarantee, or don&amp;rsquo;t ship it. Viper never advertised HOCON either, so declining costs nothing against any parity claim I might want to make later. It&amp;rsquo;s out. Meanwhile XML, INI and Java properties ship deliberately read-only, and say so on the tin. A half-working structure-preserving writer that mangles someone&amp;rsquo;s file is worse than no writer at all, and &amp;ldquo;we support writing&amp;rdquo; isn&amp;rsquo;t a claim I want to make loosely.&lt;/p&gt;&#10;&lt;h2 id="every-last-one-of-them"&gt;Every last one of them&#10;&lt;/h2&gt;&lt;p&gt;Same decision, made three times over, gave three families. A module per format, a module per filesystem, a module per backend system. Each one gets its own approved spec before anyone writes a line, because the expensive part of a remote adapter is understanding the other system&amp;rsquo;s semantics rather than writing the Go.&lt;/p&gt;&#10;&lt;p&gt;Here&amp;rsquo;s the lot of them as they stand. Each links to its page on the core docs, because adapters don&amp;rsquo;t get their own docs site in this estate&amp;hellip; they carry a strong README and signpost home.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Adapter&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Family&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;What it gives you&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/json/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-json&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;format&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;JSON, read and write&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/toml/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-toml&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;format&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;TOML, read and write&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/hcl/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-hcl&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;format&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;HCL as configuration, never evaluated&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/dotenv/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-dotenv&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;format&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;.env&lt;/code&gt; files&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/ini/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-ini&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;format&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;INI, read-only&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/properties/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-properties&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;format&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Java properties, read-only&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/xml/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-xml&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;format&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;XML, read-only&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/iofs/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-iofs&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;filesystem&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;any &lt;code&gt;io/fs.FS&lt;/code&gt;, including &lt;code&gt;embed&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/afero/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-afero&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;filesystem&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;afero&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/billy/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-billy&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;filesystem&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;go-billy, as used by go-git&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/sftp/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-sftp&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;filesystem&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;config over SSH&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/aws-s3/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-aws-s3&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;filesystem&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;an S3 bucket as a filesystem&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/gcp-gcs/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-gcp-gcs&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;filesystem&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Google Cloud Storage&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/azure-blob/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-azure-blob&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;filesystem&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Azure Blob Storage&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/consul/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-consul&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Consul KV&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/etcd/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-etcd&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;etcd v3, with real compare-and-swap&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/vault/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-vault&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;HashiCorp Vault&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/filekv/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-filekv&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;mounted files, so Kubernetes secrets&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/keychain/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-keychain&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the OS keychain&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/aws-ssm/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-aws-ssm&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;SSM Parameter Store&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/aws-secrets/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-aws-secrets&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Secrets Manager&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/gcp-parameter/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-gcp-parameter&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;GCP Parameter Manager&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/gcp-secret/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-gcp-secret&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;GCP Secret Manager&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/azure-appconfig/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-azure-appconfig&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Azure App Configuration&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/azure-keyvault/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;config-azure-keyvault&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;backend&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Azure Key Vault&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Twenty-five. That number was twenty-four when I started writing this, because &lt;code&gt;config-etcd&lt;/code&gt; shipped while the draft was open! That&amp;rsquo;s either a decent argument for the architecture or an argument about my inability to finish anything. And the one I was convinced would break the taxonomy didn&amp;rsquo;t. &lt;a class="link" href="https://keryx.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;keryx&lt;/a&gt; wanted to keep credentials in the OS keychain, and for a good while I couldn&amp;rsquo;t have told you which family that belonged to. Looked a bit like a filesystem. Behaved a bit like a backend.&lt;/p&gt;&#10;&lt;p&gt;Turns out it&amp;rsquo;s a backend, and a fairly unremarkable one at that. macOS Keychain, Windows Credential Manager, Secret Service on Linux, sat in the layer stack like anything else, so a refresh token finally has somewhere to live that isn&amp;rsquo;t a plaintext file sat next to the port number. Three tidy categories, twenty-five modules, and the awkward one filed itself. I&amp;rsquo;ll take that. The twenty-sixth will be along before I&amp;rsquo;ve finished the next post, no doubt&amp;hellip;&lt;/p&gt;</content:encoded></item><item><title>YAML has comments for a reason</title><link>https://phpboyscout.uk/yaml-has-comments-for-a-reason/</link><pubDate>Mon, 14 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/yaml-has-comments-for-a-reason/</guid><category>yamldoc</category><category>First Light</category><description>yamldoc is the editing half of YAML for Go: change one key and every byte you did not touch comes back exactly as it went in, comments included. It now has an engine of its own, and the numbers to show why.</description><content:encoded>&lt;p&gt;There&amp;rsquo;s a file on your machine that a person wrote by hand. A config file, probably. It has comments in it, because whoever wrote it knew the next person would need them: why the port is that port, which flag needs a firewall change, a note to keep a list alphabetical. The blank lines are where they are on purpose. So is the alignment of the inline comments, because a person lined them up.&lt;/p&gt;&#10;&lt;p&gt;Now change one setting in that file with a program, and look at the diff.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;ve spent most of a weekend on that diff (it&amp;rsquo;s the Monday after as I write this, and I&amp;rsquo;m a tad cross-eyed), so bear with me while I explain why.&lt;/p&gt;&#10;&lt;h2 id="two-jobs-one-word"&gt;Two jobs, one word&#10;&lt;/h2&gt;&lt;p&gt;&amp;ldquo;A YAML library&amp;rdquo; covers two different tools that happen to share a file format, and most of the fickleness people complain about (and YAML is fickle, in every language I&amp;rsquo;ve used it from) comes from using the one for the other&amp;rsquo;s job.&lt;/p&gt;&#10;&lt;p&gt;A &lt;em&gt;value&lt;/em&gt; library turns YAML into Go values and back. It&amp;rsquo;s what &lt;code&gt;yaml.Unmarshal&lt;/code&gt; into a struct is for, it&amp;rsquo;s fast, and it&amp;rsquo;s lossy by design: comments, key order, quoting, blank lines and layout have no representation in a &lt;code&gt;map[string]any&lt;/code&gt;, so they can&amp;rsquo;t survive one. That&amp;rsquo;s fine for data a machine wrote and a machine will read.&lt;/p&gt;&#10;&lt;p&gt;An &lt;em&gt;editing&lt;/em&gt; library changes one thing in a document and leaves the rest exactly as the author wrote it. That&amp;rsquo;s what a &lt;code&gt;config set&lt;/code&gt; command needs, and a settings screen over a file people also edit by hand, and a bot that bumps a version across two hundred repositories, and anything at all where the change is going to be reviewed. Every other ecosystem I can think of keeps the two apart: Rust has &lt;code&gt;toml&lt;/code&gt; and &lt;code&gt;toml_edit&lt;/code&gt;, HashiCorp has &lt;code&gt;hclsyntax&lt;/code&gt; and &lt;code&gt;hclwrite&lt;/code&gt;, Python has &lt;code&gt;tomli&lt;/code&gt; and &lt;code&gt;tomlkit&lt;/code&gt;. Go had the value half many times over and the editing half not at all.&lt;/p&gt;&#10;&lt;p&gt;I needed a rock solid YAML editor, not a values library. So I built one. It&amp;rsquo;s called &lt;a class="link" href="https://yamldoc.go.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;yamldoc&lt;/a&gt;, the docs site is the place to start, and the rest of this post is why it exists.&lt;/p&gt;&#10;&lt;h2 id="built-on-someone-elses-assumptions"&gt;Built on someone else&amp;rsquo;s assumptions&#10;&lt;/h2&gt;&lt;p&gt;The first version wasn&amp;rsquo;t a library of its own. It was the document layer of &lt;a class="link" href="https://config.go.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;go/config&lt;/a&gt;&amp;rsquo;s Store, the bit that writes a value back to your file, spun out into its own module the same day it was written, and it was functional&amp;hellip; but not perfect. It sat on &lt;a class="link" href="https://github.com/goccy/go-yaml" target="_blank" rel="noopener"&#10; &gt;goccy/go-yaml&lt;/a&gt;, which is a fantastic parser and, for a while, the best syntax tree of the bunch. It also had 149 open issues and 70 open pull requests at the time of writing, with issues going back to 2020 and pull requests waiting since 2022, so the odds of getting a contribution in were starting to look slim. There are more active forks, of course, and I&amp;rsquo;d have happily lived on one of them if the problem had been a bug.&lt;/p&gt;&#10;&lt;p&gt;The problem was an assumption. Comments in a file were effectively ignored&amp;hellip; not discarded, goccy is better than that, just ignored. They get attached to the nearest token and carried along in the hope they&amp;rsquo;ll land somewhere sensible on the way out. Every editing feature I wanted was built on someone else&amp;rsquo;s idea of what an editor needed, and I needed more than that: higher fidelity, more opinionated in the places I cared about and less in the places I didn&amp;rsquo;t, and it absolutely had to honour the YAML specifications rather than one library&amp;rsquo;s reading of them.&lt;/p&gt;&#10;&lt;p&gt;Comments should be first-class citizens of a file, not an afterthought. YAML isn&amp;rsquo;t just a machine-readable format, it&amp;rsquo;s meant to be human-readable too&amp;hellip; why else would a serialisation spec include comments at all? For pure machine readability, well, that&amp;rsquo;s what JSON is for!&lt;/p&gt;&#10;&lt;p&gt;So over a weekend (a long one, more on that in a minute) the wrapper became something I wasn&amp;rsquo;t prepared to keep, and yamldoc got an engine of its own. No parser underneath it. It&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/go/yamldoc/-/wikis/specs/0008-an-owned-yaml-engine" target="_blank" rel="noopener"&#10; &gt;spec 0008&lt;/a&gt; if you want the hundred and seventy-odd decisions that took, and I suspect that&amp;rsquo;s a post in itself.&lt;/p&gt;&#10;&lt;h2 id="what-it-does-to-a-file"&gt;What it does to a file&#10;&lt;/h2&gt;&lt;p&gt;Let&amp;rsquo;s take a file. A small one, with the things a person puts in a file: a comment explaining a decision, two inline notes lined up so they read as a column, and a blank line between the sections because they are different sections.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Which port the public listener binds to.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Changing it needs a firewall change too.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;8080&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# not 80, we sit behind a proxy&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;localhost &lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# loopback only in dev&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Feature flags. Keep alphabetical.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;beta_ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the job. A user runs &lt;code&gt;myapp config set server.port 9090&lt;/code&gt;. One key, one new value, and the file should come back with one line different. I ran that edit through three libraries, four ways, each through its own API (the harness and the exact versions are linked after the outputs), and what follows is their output, byte for byte.&lt;/p&gt;&#10;&lt;h3 id="the-official-library-the-map-route"&gt;The official library, the map route&#10;&lt;/h3&gt;&lt;p&gt;&lt;code&gt;go.yaml.in/yaml/v3&lt;/code&gt; is the YAML organisation&amp;rsquo;s own Go library (&lt;a class="link" href="https://github.com/yaml/go-yaml" target="_blank" rel="noopener"&#10; &gt;yaml/go-yaml&lt;/a&gt;), the continuation of the &lt;code&gt;gopkg.in/yaml.v3&lt;/code&gt; everybody learned on, which is archived now; the two produce identical bytes here, and so does the v4 release candidate. The usual route is &lt;code&gt;yaml.Unmarshal&lt;/code&gt; into a map, change the value, &lt;code&gt;yaml.Marshal&lt;/code&gt; back out. At its defaults you get this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;beta_ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;localhost&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;9090&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The port changed, so in one sense it worked. Everything else is gone. Both comment blocks, the two inline notes, the blank line. The indentation has doubled, because four spaces is the marshaller&amp;rsquo;s default. And look at the order: &lt;code&gt;features&lt;/code&gt; now comes before &lt;code&gt;server&lt;/code&gt;, and &lt;code&gt;host&lt;/code&gt; before &lt;code&gt;port&lt;/code&gt;, because a map has no order and the marshaller sorts keys alphabetically on the way out. The author wrote the port first because it&amp;rsquo;s the one you change; the library has an opinion about that and it wins. This is what &amp;ldquo;lossy by design&amp;rdquo; looks like on a real file, and I&amp;rsquo;d stop short of calling it a bug, because it&amp;rsquo;s a value library doing what it&amp;rsquo;s for.&lt;/p&gt;&#10;&lt;h3 id="the-official-library-the-node-route"&gt;The official library, the node route&#10;&lt;/h3&gt;&lt;p&gt;The same library has a second door, &lt;code&gt;yaml.Node&lt;/code&gt;, which keeps comments. Walk the tree to &lt;code&gt;server.port&lt;/code&gt;, change the value, encode. With the indent set to two, because at its default this route also re-indents every line to four, you get this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Which port the public listener binds to.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Changing it needs a firewall change too.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;9090&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# not 80, we sit behind a proxy&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;localhost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# loopback only in dev&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Feature flags. Keep alphabetical.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;beta_ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Better. The order held and the comments survived. But the column of inline notes has collapsed to a single space, because the emitter has one rule for where a comment goes and applies it to every line, touched or not. And the blank line between the sections has vanished, because a blank line isn&amp;rsquo;t a node and the tree never had it. A reviewer looking at this diff sees four lines changed for a one-line edit, three of them in places no one asked for.&lt;/p&gt;&#10;&lt;h3 id="goccy-through-its-path-api"&gt;goccy, through its path API&#10;&lt;/h3&gt;&lt;p&gt;&lt;code&gt;goccy/go-yaml&lt;/code&gt; is the library the first yamldoc sat on. Through its path API it does this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Which port the public listener binds to.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Changing it needs a firewall change too.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;9090&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;localhost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# loopback only in dev&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Feature flags. Keep alphabetical.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;beta_ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The blank line survived, which is why goccy was the best of the bunch for a long while. But the inline note on the line we edited has gone altogether, &lt;code&gt;# not 80, we sit behind a proxy&lt;/code&gt;, which is the very comment the next person needs when they come to look at that port. And the note on the line below lost its alignment. This is the &amp;ldquo;ignored, not discarded&amp;rdquo; problem from earlier: the comment is attached to a token, the token got replaced, and the comment went with it.&lt;/p&gt;&#10;&lt;h3 id="yamldoc"&gt;yamldoc&#10;&lt;/h3&gt;&lt;p&gt;And yamldoc gives you this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Which port the public listener binds to.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Changing it needs a firewall change too.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;9090&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# not 80, we sit behind a proxy&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;localhost &lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# loopback only in dev&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Feature flags. Keep alphabetical.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;beta_ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One line changed, and only the four characters of the value on it. The comment on that line is where it was, still aligned with the one below. The blank line is there. The order is the author&amp;rsquo;s. Run a diff and the diff is the bump.&lt;/p&gt;&#10;&lt;h2 id="why-it-comes-back-like-that"&gt;Why it comes back like that&#10;&lt;/h2&gt;&lt;p&gt;The reason isn&amp;rsquo;t a longer list of special cases. The others parse into a tree and emit the tree, so whatever the tree doesn&amp;rsquo;t model is gone and the emitter&amp;rsquo;s opinions apply to every line. yamldoc edits the &lt;em&gt;source&lt;/em&gt;: the bytes inside the edit&amp;rsquo;s footprint get replaced (here, &lt;code&gt;8080&lt;/code&gt;), and every other byte is handed back untouched. There&amp;rsquo;s no list of preserved constructs because there&amp;rsquo;s nothing that isn&amp;rsquo;t preserved.&lt;/p&gt;&#10;&lt;p&gt;Then it checks its own work. Every transaction re-parses the bytes it just produced, composes them fresh, and compares the result against a statement of what the edit meant that was written down &lt;em&gt;before&lt;/em&gt; the writer ran: the values, the correspondence between old and new, the untouched bytes, and which comment belongs to whom. Only if all of that agrees does the new revision get installed. A bug in the writer gives you an error and an unchanged file, not a damaged one. I&amp;rsquo;ve had the damaged one (more than once, if I&amp;rsquo;m honest) and I&amp;rsquo;d take the error every time.&lt;/p&gt;&#10;&lt;p&gt;That&amp;rsquo;s one file, hand-picked to make the point. The measured version runs every library that can round-trip a document over eight real project config files, first with no edit and then setting each of seventy reachable scalars through the library&amp;rsquo;s own API:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Files back byte-identical with no edit: yamldoc 8 of 8, the next best 5.&lt;/li&gt;&#10;&lt;li&gt;Single-key edits that changed one line and one line only: yamldoc 70 of 70, the next best 24.&lt;/li&gt;&#10;&lt;li&gt;The official YAML test suite: all 306 valid cases parsed and all 94 invalid ones rejected. 304 match the suite&amp;rsquo;s pinned events and the other two match libyaml instead, and I&amp;rsquo;d rather tell you that than round it up. I did ask whether we could close the gap. The answer was no, and it&amp;rsquo;s good enough now we can explain it.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The harness, the versions and the raw output are in &lt;a class="link" href="https://gitlab.com/phpboyscout/go/yamldoc/-/wikis/reports/2026-09-13-yaml-library-comparison" target="_blank" rel="noopener"&#10; &gt;the comparison report&lt;/a&gt;, measured on the 13th of September, and the construct-by-construct table (CRLF, emoji, a comment before &lt;code&gt;---&lt;/code&gt;, a missing final newline, and the rest) is on the &lt;a class="link" href="https://yamldoc.go.phpboyscout.uk/explanation/when-to-use-yamldoc/" target="_blank" rel="noopener"&#10; &gt;Should you use yamldoc?&lt;/a&gt; page, which is the plain version of that question, including the bit where the answer is no.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;The four outputs above came from &lt;a class="link" href="https://gitlab.com/phpboyscout/go/yamldoc/-/snippets/6054969" target="_blank" rel="noopener"&#10; &gt;this harness&lt;/a&gt; on Go 1.27.1: &lt;code&gt;go.yaml.in/yaml/v3&lt;/code&gt; v3.0.5 (the archived &lt;code&gt;gopkg.in/yaml.v3&lt;/code&gt; v3.0.1 and the &lt;code&gt;go.yaml.in/yaml/v4&lt;/code&gt; v4.0.0-rc.6 candidate return the same bytes on this file), &lt;code&gt;goccy/go-yaml&lt;/code&gt; v1.19.2 and yamldoc v0.6.1. Run it yourself, that&amp;rsquo;s rather the point.&lt;/em&gt;&lt;/p&gt;&#10;&lt;h2 id="what-it-costs"&gt;What it costs&#10;&lt;/h2&gt;&lt;p&gt;In the same breath, because it isn&amp;rsquo;t free. An edit costs four to seven times what the value libraries charge, and a decode about three times, because yamldoc keeps everything (every token, the whitespace, the comments, the spelling of every number) and then pays again to check the result. A parse is level with goccy. And a parse followed by writing the file back with no change is free, level with the fastest of them, because it never re-emits: it hands you the bytes it started with. &lt;a class="link" href="https://yamldoc.go.phpboyscout.uk/explanation/performance/" target="_blank" rel="noopener"&#10; &gt;Why an edit costs more&lt;/a&gt; is the accounting, stage by stage, and the instruction I gave for that page was to justify the expense against the safety, not to hide it.&lt;/p&gt;&#10;&lt;h2 id="what-it-cost-me"&gt;What it cost me&#10;&lt;/h2&gt;&lt;p&gt;The weekend, mostly, and ninety per cent of an OpenAI Pro subscription overnight. One run got carried away, seven hours of tokens on scaffolding and tests and precious little engine, because I hadn&amp;rsquo;t given it enough direction to know when to prefer implementation over testing. That&amp;rsquo;s on me, and it&amp;rsquo;s a war story for another day. I don&amp;rsquo;t think it was wasted, though&amp;hellip; it was a real push of a new model and the results speak for themselves: a rock solid spec and testing out the wazoo. The bill for what it costs me long-term hasn&amp;rsquo;t come due yet&amp;hellip; I&amp;rsquo;m happy with it so far.&lt;/p&gt;&#10;&lt;h2 id="who-its-for"&gt;Who it&amp;rsquo;s for&#10;&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://keryx.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;keryx&lt;/a&gt; uses it directly, to save its workspace file without wrecking what a person typed into it. Perhaps more to the point, any project built on &lt;a class="link" href="https://gtb.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;go-tool-base&lt;/a&gt; relies on yamldoc as a default part of its configuration management, so as I carry on building cool shit, more and more of the estate ends up standing on it whether it knows or not. That&amp;rsquo;s the reason it has bounds on everything (bytes, nodes, depth, alias expansion, work per transaction) and refuses a hostile file with an error rather than a large allocation.&lt;/p&gt;&#10;&lt;p&gt;It is not a struct decoder and it won&amp;rsquo;t become one. Use it &lt;em&gt;with&lt;/em&gt; a value library, not instead of one. If you&amp;rsquo;re reading YAML into a struct, &lt;code&gt;go.yaml.in/yaml/v4&lt;/code&gt; or goccy is the right tool and yamldoc will only get in your way. If you&amp;rsquo;re changing a file that a person maintains, this is the one. The docs are at &lt;strong&gt;&lt;a class="link" href="https://yamldoc.go.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;yamldoc.go.phpboyscout.uk&lt;/a&gt;&lt;/strong&gt;, &lt;a class="link" href="https://yamldoc.go.phpboyscout.uk/tutorials/getting-started/" target="_blank" rel="noopener"&#10; &gt;getting started&lt;/a&gt; says to allow fifteen minutes, and the install is the usual:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go get gitlab.com/phpboyscout/go/yamldoc&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;yamldoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;yamldoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;server.port&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9090&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Parse bytes, change one key, write bytes back. The file comes back as its author wrote it, comment and all, and I&amp;rsquo;d suggest that&amp;rsquo;s what the spec had in mind when it gave you comments in the first place.&lt;/p&gt;</content:encoded></item><item><title>Layering config without losing where a value came from</title><link>https://phpboyscout.uk/layering-config-without-losing-where-a-value-came-from/</link><pubDate>Thu, 10 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/layering-config-without-losing-where-a-value-came-from/</guid><category>go-config</category><category>Orienteering</category><description>Four places a config value can come from, and the only question that matters at the wrong end of an incident: which one won, and why. Twenty minutes, one module, no services.</description><content:encoded>&lt;p&gt;Someone edits a setting, restarts the service, and nothing changes. The file is right. You can cat it back and it says exactly what they typed. The process carries on doing the old thing regardless, and now there&amp;rsquo;s a call open and four different places that value could be coming from.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;ve lost afternoons to that, and the annoying part is that it&amp;rsquo;s never really a mystery. It&amp;rsquo;s just that nothing in the process can tell you which layer won.&lt;/p&gt;&#10;&lt;p&gt;So this walkthrough builds the whole chain a real service uses, defaults under a file under the environment under flags, and then spends most of its time on the more useful half: asking the thing which layer supplied a value, which layers lost, and what would happen if you wrote to it. Twenty minutes, one module, no services and no network past the first &lt;code&gt;go get&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If you want the argument for why config is its own module rather than something the framework owns, &lt;a class="link" href="https://phpboyscout.uk/config-is-a-border-not-a-chain/" &gt;that&amp;rsquo;s a separate post&lt;/a&gt;. This one is just using it.&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;This is the canonical walkthrough from the go/config docs, reproduced here so the two don&amp;rsquo;t drift. The docs are the source of truth: &lt;strong&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/tutorials/layering/" target="_blank" rel="noopener"&#10; &gt;https://config.go.phpboyscout.uk/tutorials/layering/&lt;/a&gt;&lt;/strong&gt;. I built the module and ran every step against &lt;strong&gt;v0.17.2&lt;/strong&gt; on Go 1.27.0 before this went out, and every output below is what came back, with one divergence flagged in step 6.&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;&lt;h2 id="1-create-a-module"&gt;1. Create a module&#10;&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mkdir cfgdemo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; cfgdemo&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go mod init cfgdemo&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go get gitlab.com/phpboyscout/go/config github.com/spf13/pflag&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;pflag&lt;/code&gt; is only for the flags layer in step 5. The other three need nothing beyond the module itself, which is rather the point of it having been extracted.&lt;/p&gt;&#10;&lt;h2 id="2-compile-the-defaults-into-the-binary"&gt;2. Compile the defaults into the binary&#10;&lt;/h2&gt;&lt;p&gt;A default isn&amp;rsquo;t a config file. It ships inside the binary, so the program starts correctly on a machine with no configuration at all, and it can never be written to, because there&amp;rsquo;s nowhere to persist it. That last bit matters later than you&amp;rsquo;d think.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;main.go&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;builtinDefaults&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;server:&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; host: 0.0.0.0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; port: 8080&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; timeout: 30&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;log:&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; level: info&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NamedSource&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;embedded:defaults.yaml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;builtinDefaults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;View&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;server.host&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;server.port&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;server.timeout&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;log.level&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;%-16s = %-10v %s\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; go run .&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;server.host = 0.0.0.0 server.host = 0.0.0.0 (from default:embedded:defaults.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;server.port = 8080 server.port = 8080 (from default:embedded:defaults.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;server.timeout = 30 server.timeout = 30 (from default:embedded:defaults.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;log.level = info log.level = info (from default:embedded:defaults.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Give the source a name you&amp;rsquo;d recognise at the wrong end of an incident. &lt;code&gt;embedded:defaults.yaml&lt;/code&gt; tells you where to go and look; &lt;code&gt;reader1&lt;/code&gt; tells you nothing, and provenance is most of what this module is for.&lt;/p&gt;&#10;&lt;h2 id="3-put-a-config-file-over-the-top"&gt;3. Put a config file over the top&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;config.yaml&lt;/code&gt;, next to &lt;code&gt;main.go&lt;/code&gt;. Keep the comments, step 7 comes back to them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Deployment settings for the demo service.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# The port the operators agreed on.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;9090&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;debug&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It sets two of the four keys deliberately. Add it to the store &lt;strong&gt;after&lt;/strong&gt; the defaults:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fsys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithReaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NamedSource&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;embedded:defaults.yaml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;builtinDefaults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fsys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;config.yaml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; go run .&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;server.host = 0.0.0.0 server.host = 0.0.0.0 (from default:embedded:defaults.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;server.port = 9090 server.port = 9090 (from config.yaml); also defined in default:embedded:defaults.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;server.timeout = 30 server.timeout = 30 (from default:embedded:defaults.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;log.level = debug log.level = debug (from config.yaml); also defined in default:embedded:defaults.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Precedence is the order you added the sources, and later wins.&lt;/strong&gt; No ranking table to learn, no priority number to set. The file outranks the defaults because &lt;code&gt;WithFiles&lt;/code&gt; came after &lt;code&gt;WithReaders&lt;/code&gt;, and if you want it the other way round you move the line.&lt;/p&gt;&#10;&lt;p&gt;Two keys came from the file, two from the defaults, and the merge happened &lt;strong&gt;per key&lt;/strong&gt; rather than per file. A file that sets one key doesn&amp;rsquo;t blank out the rest of the tree, which sounds obvious right up until you meet a library that does it the other way.&lt;/p&gt;&#10;&lt;h2 id="4-add-the-environment"&gt;4. Add the environment&#10;&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;CFGDEMO&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; &lt;span class="nv"&gt;CFGDEMO_SERVER_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;7000&lt;/span&gt; go run .&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;server.port = 7000 server.port = 7000 (from env:CFGDEMO_SERVER_PORT); also defined in default:embedded:defaults.yaml, config.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;CFGDEMO_SERVER_PORT&lt;/code&gt; became &lt;code&gt;server.port&lt;/code&gt;: prefix stripped, the rest lowercased, underscores into dots.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;That prefix isn&amp;rsquo;t a nicety, it&amp;rsquo;s a security control.&lt;/strong&gt; Without one, every variable in the process environment is a candidate configuration key: &lt;code&gt;PATH&lt;/code&gt;, &lt;code&gt;HOME&lt;/code&gt;, a token injected for something else entirely. The full rules, including what happens when an underscore is ambiguous, are in &lt;a class="link" href="https://config.go.phpboyscout.uk/reference/environment-variables/" target="_blank" rel="noopener"&#10; &gt;Environment variables&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="5-add-flags-on-top"&gt;5. Add flags on top&#10;&lt;/h2&gt;&lt;p&gt;Flags go last, because the person typing one has the most immediate intent of anybody involved.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pflag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewFlagSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;demo&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pflag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ExitOnError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;server-port&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;port to listen on&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;log-level&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;log verbosity&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;and as the last store option:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithFlags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; &lt;span class="nv"&gt;CFGDEMO_SERVER_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;7000&lt;/span&gt; go run . --server-port &lt;span class="m"&gt;6000&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;server.port = 6000 server.port = 6000 (from flag:--server-port); also defined in default:embedded:defaults.yaml, config.yaml, env:CFGDEMO_SERVER_PORT&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One value, four layers that could have supplied it, and the top one won. Dashes become dots the same way underscores do. When a flag&amp;rsquo;s name and its key genuinely differ, &lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/bind-cli-flags/" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;BindFlag&lt;/code&gt;&lt;/a&gt; is the way.&lt;/p&gt;&#10;&lt;h3 id="the-bit-worth-stopping-on-a-flag-you-didnt-type-contributes-nothing"&gt;The bit worth stopping on: a flag you didn&amp;rsquo;t type contributes nothing&#10;&lt;/h3&gt;&lt;p&gt;&lt;code&gt;server-port&lt;/code&gt; has a declared default of &lt;code&gt;0&lt;/code&gt;. Run without it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; go run . --log-level warn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;server.port = 9090 server.port = 9090 (from config.yaml); also defined in default:embedded:defaults.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;log.level = warn log.level = warn (from flag:--log-level); also defined in default:embedded:defaults.yaml, config.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;server.port&lt;/code&gt; is 9090, not 0. &lt;strong&gt;Only flags actually set on the command line join the layer&lt;/strong&gt;, because the store asks pflag which flags were &lt;em&gt;visited&lt;/em&gt; rather than reading what every flag&amp;rsquo;s value happens to be. Skip that and every unset flag&amp;rsquo;s zero value sits at the top of the stack burying every layer underneath, which makes the whole flags layer worse than useless.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s also why declared flag defaults are the wrong home for configuration defaults. Put those in the defaults layer, as in step 2, where anything can override them.&lt;/p&gt;&#10;&lt;h2 id="6-ask-which-layer-won-and-which-lost"&gt;6. Ask which layer won, and which lost&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Explain&lt;/code&gt; is the readable form you&amp;rsquo;ve been printing all along. Two lower-level calls give you the same facts as data, which is what you want when you&amp;rsquo;re rendering them somewhere:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;View&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Origin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;server.port&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;winner: %s (writable: %v)\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Writable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;defined&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;View&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;DefinedIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;server.port&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;lost: %s\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Origin&lt;/code&gt; names the layer whose value you actually get. &lt;code&gt;DefinedIn&lt;/code&gt; lists &lt;strong&gt;every&lt;/strong&gt; layer that defines the key, lowest precedence first, so its last entry is that same winner and everything before it lost. Slicing the last one off gives you the losers, which is how &lt;code&gt;Explain&lt;/code&gt; builds its &amp;ldquo;also defined in&amp;rdquo; list, and it&amp;rsquo;s why the loop checks for more than one: a key only one layer sets comes back as a one-entry list. &lt;code&gt;Writable&lt;/code&gt; on a &lt;code&gt;Source&lt;/code&gt; is the field the next step turns on.&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;&lt;strong&gt;One that caught me out.&lt;/strong&gt; That method used to be called &lt;code&gt;Shadowed&lt;/code&gt;, and the tutorial said it listed the sources that defined the key &lt;em&gt;and lost&lt;/em&gt;. At v0.17.2 it handed me the winner as well, so the old loop printed &lt;code&gt;env:CFGDEMO_SERVER_PORT&lt;/code&gt; as having been shadowed by&amp;hellip; itself. I raised it as &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/work_items/10" target="_blank" rel="noopener"&#10; &gt;go/config#10&lt;/a&gt;, and the answer was that the code was right and the docs were wrong: returning the whole chain is the specified behaviour, and the tutorial now says so. Fair cop. It&amp;rsquo;s since been renamed &lt;code&gt;DefinedIn&lt;/code&gt;, which says what it does on the tin, and &lt;code&gt;Shadowed&lt;/code&gt; hangs around as a deprecated alias, so on an older go/config the same loop works with the old name. With the loop above, the same run prints:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;winner: env:CFGDEMO_SERVER_PORT (writable: false)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;lost: default:embedded:defaults.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;lost: config.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/blockquote&gt;&#10;&lt;h2 id="7-write-a-value-back-and-watch-routing-skip-what-it-cant-write"&gt;7. Write a value back, and watch routing skip what it can&amp;rsquo;t write&#10;&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s where the defaults layer being unwritable stops being trivia. &lt;code&gt;server.timeout&lt;/code&gt; exists &lt;strong&gt;only&lt;/strong&gt; in the compiled-in defaults, a layer with nowhere to persist to. Ask where a change would go before making it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;server.timeout&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Operations&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;plan: %s -&amp;gt; %s (effective: %v)\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Effective&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;plan: server.timeout -&amp;gt; config.yaml (effective: true)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Routing walked past the defaults because they can&amp;rsquo;t be written, and picked the highest layer that can. Apply it, and &lt;code&gt;config.yaml&lt;/code&gt; becomes:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Deployment settings for the demo service.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# The port the operators agreed on.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;9090&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;debug&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Both comments survived, &lt;code&gt;port&lt;/code&gt; kept its place, and &lt;code&gt;timeout&lt;/code&gt; landed in the section it belongs to. The file was &lt;strong&gt;edited&lt;/strong&gt;, not regenerated from a parsed tree, which is the difference between a tool an operator will let near their config and one they&amp;rsquo;ll ban after the first time it eats their comments.&lt;/p&gt;&#10;&lt;h2 id="8-write-a-value-that-a-higher-layer-still-shadows"&gt;8. Write a value that a higher layer still shadows&#10;&lt;/h2&gt;&lt;p&gt;Which brings us back to the call at the top. Write &lt;code&gt;server.port&lt;/code&gt; while the environment is also setting it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-console" data-lang="console"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gp"&gt;$&lt;/span&gt; &lt;span class="nv"&gt;CFGDEMO_SERVER_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;7000&lt;/span&gt; go run .&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt;plan: server.port -&amp;gt; config.yaml (effective: false)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="go"&gt; shadowed by [env:CFGDEMO_SERVER_PORT]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The write isn&amp;rsquo;t refused. &lt;code&gt;config.yaml&lt;/code&gt; really does get &lt;code&gt;port: 5555&lt;/code&gt;, and I checked. But the plan told you, &lt;em&gt;before&lt;/em&gt; you committed to it, that reading the key back will still give you 7000:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Effective&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34; shadowed by %s\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShadowedBy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s the whole afternoon I mentioned, handed to you as a boolean. The setting saved, the file is correct, the running process ignores it, and instead of three people staring at a file that is definitely right, something can say out loud which layer is winning and why.&lt;/p&gt;&#10;&lt;h2 id="what-you-built"&gt;What you built&#10;&lt;/h2&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Layer&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Added with&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: center"&gt;Writable&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Typical source&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Defaults&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;WithReaders&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;✗&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;compiled into the binary&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;File&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;WithFiles&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;✓&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;an operator edits it&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Environment&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;WithEnv&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;✗&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the platform injects it&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Flags&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;WithFlags&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;✗&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;someone typed it&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Exactly one of the four can be written to, which is the only reason routing has a decision to make.&lt;/p&gt;&#10;&lt;p&gt;From here, &lt;a class="link" href="https://config.go.phpboyscout.uk/tutorials/consul/" target="_blank" rel="noopener"&#10; &gt;Configure a service from Consul&lt;/a&gt; adds a remote layer that behaves precisely like the four above, and &lt;a class="link" href="https://config.go.phpboyscout.uk/explanation/precedence-and-merge/" target="_blank" rel="noopener"&#10; &gt;Precedence and merge&lt;/a&gt; is the reasoning underneath all of it, including what happens to lists and maps.&lt;/p&gt;&#10;&lt;p&gt;Four layers is not the hard part. Being able to answer &lt;em&gt;why&lt;/em&gt; is.&lt;/p&gt;</content:encoded></item><item><title>When a project's config inherits yours</title><link>https://phpboyscout.uk/when-a-projects-config-inherits-yours/</link><pubDate>Sat, 19 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/when-a-projects-config-inherits-yours/</guid><category>go-config</category><category>Orienteering</category><description>Two configs for one tool, the user's and the project's, and no rule anywhere about which one wins. Twenty minutes to make one a layer of the other and keep a straight answer about where every value came from.</description><content:encoded>&lt;p&gt;keryx has a set of house styles for the cover art on this blog, and they live in a library in my home directory. Any project I point it at gets the same eight. That&amp;rsquo;s what I want, right up until a project has to build on a runner that has never seen my home directory, and then it needs its own copy pinned in the repo.&lt;/p&gt;&#10;&lt;p&gt;So that tool now has two configurations, mine and this project&amp;rsquo;s, and nothing anywhere writes down which one wins.&lt;/p&gt;&#10;&lt;p&gt;You can carry both around and remember the rule. I did that for a while. It works until the morning someone asks why a setting they definitely changed is having no effect, and the answer is four levels down a call stack in an &lt;code&gt;if&lt;/code&gt; nobody has read since it was written.&lt;/p&gt;&#10;&lt;p&gt;The better move is to stop treating them as two things. Make one config a &lt;em&gt;layer&lt;/em&gt; of the other, so there is one store, one precedence order, and one place that can tell you where a value came from.&lt;/p&gt;&#10;&lt;p&gt;This is the canonical walkthrough from the go/config docs, reproduced here so the two don&amp;rsquo;t drift: &lt;strong&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/tutorials/composing-stores/" target="_blank" rel="noopener"&#10; &gt;https://config.go.phpboyscout.uk/tutorials/composing-stores/&lt;/a&gt;&lt;/strong&gt;. Every command and output below was validated by running it against &lt;strong&gt;v0.17.3&lt;/strong&gt;, including the bit where a file gets rewritten and keeps its comments.&lt;/p&gt;&#10;&lt;p&gt;It builds directly on precedence and routed writes, so do &lt;a class="link" href="https://phpboyscout.uk/layering-config-without-losing-where-a-value-came-from/" &gt;the layering walkthrough&lt;/a&gt; first if you haven&amp;rsquo;t. Twenty minutes, Go 1.26.5 or newer, and nothing else.&lt;/p&gt;&#10;&lt;h2 id="1-create-a-module-and-two-config-files"&gt;1. Create a module and two config files&#10;&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mkdir cfgcompose &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; cfgcompose&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go mod init cfgcompose&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go get gitlab.com/phpboyscout/go/config&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;global.yaml&lt;/code&gt;, standing in for &lt;code&gt;~/.config/demo/config.yaml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# The user&amp;#39;s settings, shared by every project.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;vim&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;dark&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;telemetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;project.yaml&lt;/code&gt;, standing in for a &lt;code&gt;.demo.yaml&lt;/code&gt; in the repo:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# This project&amp;#39;s settings, checked into the repo.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;light&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;wasm&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;They overlap on exactly one key, &lt;code&gt;theme&lt;/code&gt;, which is the interesting one.&lt;/p&gt;&#10;&lt;h2 id="2-nest-the-global-store-inside-the-project-store"&gt;2. Nest the global store inside the project store&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;main.go&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;package&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;context&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;log&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;gitlab.com/phpboyscout/go/config&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fsys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;global&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fsys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;global.yaml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Nested&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;global&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;global&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NestedPromotable&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fsys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;project.yaml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;View&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;editor&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;theme&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;telemetry.enabled&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;build.target&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;%-18s = %-8v %s\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go run .&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;editor = vim editor = vim (from global.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;theme = light theme = light (from project.yaml); also defined in global.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;telemetry.enabled = false telemetry.enabled = false (from global.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;build.target = wasm build.target = wasm (from project.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Provenance survives the join. &lt;code&gt;editor&lt;/code&gt; is reported as coming from &lt;code&gt;global.yaml&lt;/code&gt;, the actual file, rather than from &amp;ldquo;the aggregate&amp;rdquo; or &amp;ldquo;the nested store&amp;rdquo;. The inner store&amp;rsquo;s layers pass through as a contiguous block at the position the backend was declared, each keeping its own source, so nothing is interleaved and nothing is anonymised.&lt;/p&gt;&#10;&lt;p&gt;That is worth pausing on. The whole reason to compose rather than juggle is to keep the straight answer at the end of it, and a join that flattens everything into one anonymous blob has thrown away the thing you were trying to protect.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;&amp;quot;global&amp;quot;&lt;/code&gt; id names the aggregate in error messages. It is not a layer name, because an aggregate contributes no layer of its own.&lt;/p&gt;&#10;&lt;h2 id="3-watch-an-ordinary-write-stay-local"&gt;3. Watch an ordinary write stay local&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;editor&lt;/code&gt; is defined only in the global file. Routing&amp;rsquo;s usual rule is to edit a key where it already lives, so does a write to it reach into the global config?&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;editor&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;helix&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Operations&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;ordinary write: editor -&amp;gt; %s\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ordinary write: editor -&amp;gt; project.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No. A nested store is never a routing candidate, even with &lt;code&gt;NestedPromotable&lt;/code&gt; passed. An ordinary project-scoped edit lands in the project&amp;rsquo;s own file, and creates the key there.&lt;/p&gt;&#10;&lt;p&gt;That asymmetry is most of the design. If routing could reach inside, a mundane &amp;ldquo;set my editor for this project&amp;rdquo; would walk past the project&amp;rsquo;s file and rewrite the shared config every other project inherits. That is the destructive version of a helpful default, and you only find out about it weeks later, when three unrelated repositories have quietly changed their minds about something.&lt;/p&gt;&#10;&lt;h2 id="4-promote-a-setting-deliberately"&gt;4. Promote a setting deliberately&#10;&lt;/h2&gt;&lt;p&gt;Promotion, moving a setting &lt;em&gt;up&lt;/em&gt; into the shared config, has to be asked for by name:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;promote&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;theme&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;solarized&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;global.yaml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;p2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;p2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Operations&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;promotion: theme -&amp;gt; %s (effective: %v)\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Effective&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Effective&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34; shadowed by %s\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShadowedBy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;promotion: theme -&amp;gt; global.yaml (effective: false)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; shadowed by [project.yaml]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;global.yaml&lt;/code&gt; really is updated, comment and all:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# The user&amp;#39;s settings, shared by every project.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;vim&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;solarized&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;telemetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But &lt;code&gt;effective: false&lt;/code&gt; is the bit you want. The project file still sets &lt;code&gt;theme: light&lt;/code&gt;, so reading &lt;code&gt;theme&lt;/code&gt; back in &lt;em&gt;this&lt;/em&gt; project still gives you &lt;code&gt;light&lt;/code&gt;. The promotion will show up in every other project, and not in this one until the local override goes.&lt;/p&gt;&#10;&lt;p&gt;Tell the user that, because it is the difference between a tool that saved their setting and one that appears to have ignored them.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;NestedPromotable&lt;/code&gt; is what made this possible at all. Without it a nested store is strictly read-only and even a named write cannot reach in, which is the safe default and the usual case: a shared organisational base, a team standard nobody should be editing by accident.&lt;/p&gt;&#10;&lt;h2 id="5-bound-what-the-inner-store-may-contribute"&gt;5. Bound what the inner store may contribute&#10;&lt;/h2&gt;&lt;p&gt;A nested store contributes everything it can see. When that is a shared config carrying settings this tool has no business reading, wrap it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Filtered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Nested&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;global&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;global&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NestedPromotable&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;telemetry.*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;editor = vim editor = vim (from global.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;theme = light theme = light (from project.yaml); also defined in global.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;telemetry.enabled = &amp;lt;nil&amp;gt; telemetry.enabled is not set&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;build.target = wasm build.target = wasm (from project.yaml)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;telemetry.enabled&lt;/code&gt; is not merely hidden from reads. It &lt;em&gt;is not set&lt;/em&gt;, as far as this store is concerned, so &lt;code&gt;Has&lt;/code&gt; is false and a write would not be accepted for it either. Hiding a value from reads while still letting something write to it is the worst of both, so the filter applies to the whole surface.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;Allow&lt;/code&gt; is the other half. With no &lt;code&gt;Allow&lt;/code&gt;, every key is permitted unless a &lt;code&gt;Deny&lt;/code&gt; excludes it; with one, only matching keys get through. &lt;code&gt;Deny&lt;/code&gt; beats &lt;code&gt;Allow&lt;/code&gt; where they overlap, so an &lt;code&gt;Allow&lt;/code&gt; can never re-expose something explicitly denied.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;Filtered&lt;/code&gt; wraps any backend, not just a nested store. The same call bounds a Consul prefix that a broad token happens to be able to read.&lt;/p&gt;&#10;&lt;h2 id="6-push-a-runtime-override-above-everything"&gt;6. Push a runtime override above everything&#10;&lt;/h2&gt;&lt;p&gt;Some values get decided while the program is running: a &lt;code&gt;--set&lt;/code&gt; flag, something fetched at startup, a test fixture. &lt;code&gt;AddLayer&lt;/code&gt; puts one above every layer declared at construction.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddLayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;cli-override&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;theme: high-contrast\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;theme = high-contrast theme = high-contrast (from override:cli-override); also defined in global.yaml, project.yaml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An override layer is read-only, because there is nowhere to persist it, and it survives reloads, because it is re-read each time rather than merged in once. If its content will not parse it is refused and withdrawn, leaving the last known good configuration live rather than half-applying something broken.&lt;/p&gt;&#10;&lt;p&gt;One restriction worth knowing before you hit it: calling &lt;code&gt;AddLayer&lt;/code&gt; from inside an observer returns &lt;code&gt;ErrWriteFromObserver&lt;/code&gt;. Observers see a snapshot, and letting one mutate the store mid-notification is how notification ordering stops being defined.&lt;/p&gt;&#10;&lt;h2 id="what-you-built"&gt;What you built&#10;&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;override:cli-override ← AddLayer, runtime, read-only, highest&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;project.yaml ← the repo&amp;#39;s config, writable, routing&amp;#39;s target&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;global.yaml ← via Nested + Filtered, promotable by name only&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Call&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;What it does&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;config.Nested(s, id)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;inner store&amp;rsquo;s layers become layers of the outer one, read-only&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;config.NestedPromotable&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;a named write may reach in; routing still may not&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;config.Filtered(b, ...)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;bounds which keys a backend contributes, and accepts writes for&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;config.To(&amp;quot;name&amp;quot;)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;pins one change to a named layer&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;Store.AddLayer&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;a read-only layer above everything, added at runtime&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h2 id="where-to-go-next"&gt;Where to go next&#10;&lt;/h2&gt;&lt;p&gt;The docs carry the task-shaped versions of all of this: &lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/compose-stores/" target="_blank" rel="noopener"&#10; &gt;composing stores&lt;/a&gt; including cycles and reload semantics, &lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/filter-a-backend/" target="_blank" rel="noopener"&#10; &gt;filtering a backend&lt;/a&gt; with the full pattern syntax, and &lt;a class="link" href="https://config.go.phpboyscout.uk/how-to/write-config/" target="_blank" rel="noopener"&#10; &gt;writing configuration&lt;/a&gt; with routing and targets in full. &lt;a class="link" href="https://config.go.phpboyscout.uk/explanation/the-store/" target="_blank" rel="noopener"&#10; &gt;The Store&lt;/a&gt; is the why underneath: one component owning all configuration I/O is what makes composing them safe.&lt;/p&gt;&#10;&lt;p&gt;And if you want the argument for config being its own module rather than something the framework hands you, &lt;a class="link" href="https://phpboyscout.uk/config-is-a-border-not-a-chain/" &gt;that one&amp;rsquo;s separate&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;As for keryx, it now does just this. The eight styles come from my library, the project pins whatever it needs to build unattended, and when I can&amp;rsquo;t remember which one a cover actually used, I can ask.&lt;/p&gt;</content:encoded></item><item><title>The bill for fifty-one modules</title><link>https://phpboyscout.uk/the-bill-for-fifty-one-modules/</link><pubDate>Thu, 08 Oct 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-bill-for-fifty-one-modules/</guid><category>go-tool-base</category><category>Pioneering</category><description>Change one line in the tls module and you've signed up for twelve releases. That's not a bug in the architecture, that's the architecture.</description><content:encoded>&lt;p&gt;Change one line in the &lt;code&gt;tls&lt;/code&gt; module and you have signed yourself up for twelve releases (releases, not commits), in four sequential waves, each one waiting on the one before it, and every single one gated on a human pressing a button. I know, because I was the human.&lt;/p&gt;&#10;&lt;h2 id="what-the-review-actually-called-it"&gt;What the review actually called it&#10;&lt;/h2&gt;&lt;p&gt;I had an architectural review run across the whole toolkit last July, and it came back with sixty-odd findings, the usual mix of things I already knew and things I&amp;rsquo;d rather not have. The one that mattered wasn&amp;rsquo;t a bug at all. It was theme five, and it got a title I&amp;rsquo;ve not been able to un-see:&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;&lt;strong&gt;Propagation debt as the ecosystem&amp;rsquo;s principal systemic risk.&lt;/strong&gt; The many-small-modules architecture is clean but expensive: a tls change fans out into 12 human-gated releases across 4 sequential waves; a config change into ~20–23.&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;&lt;p&gt;&lt;em&gt;Principal systemic risk.&lt;/em&gt; Not the credential-timeout bug or the redirect-token hardening&amp;hellip; the way I&amp;rsquo;d built it! At that point there were fifty-one modules.&lt;/p&gt;&#10;&lt;h2 id="why-it-costs-what-it-costs"&gt;Why it costs what it costs&#10;&lt;/h2&gt;&lt;p&gt;The maths isn&amp;rsquo;t complicated, it&amp;rsquo;s just relentless. &lt;code&gt;tls&lt;/code&gt; sits near the bottom, above it are the transports, above those the framework, and above the framework the tools. Change &lt;code&gt;tls&lt;/code&gt; and nothing above it moves until &lt;code&gt;tls&lt;/code&gt; cuts a release, then its direct consumers bump and &lt;em&gt;they&lt;/em&gt; cut releases, then their consumers bump. Four layers deep, twelve artefacts, and each hop is a merge request I have to look at.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;m not sure any of it counts as waste, exactly. Each of those releases is a real version of a real module that did change, and it&amp;rsquo;s the correct behaviour of a correctly built dependency graph.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s also most of a night, for one line.&lt;/p&gt;&#10;&lt;h2 id="the-finding-underneath-the-finding"&gt;The finding underneath the finding&#10;&lt;/h2&gt;&lt;p&gt;What I keep coming back to isn&amp;rsquo;t the twelve so much as this:&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;The evidence shows propagation stalls where fan-out is largest.&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;&lt;p&gt;It isn&amp;rsquo;t the obvious thing. You&amp;rsquo;d assume the big fan-outs get done first, on the grounds that they&amp;rsquo;re the important ones, and the opposite happens: the change with twelve downstream releases is the one you look at late on a Friday and decide is a problem for next week, and then the week after, while the modules furthest down the chain sit three minors stale and the fix waits, merged and unreleased, at the bottom.&lt;/p&gt;&#10;&lt;p&gt;So the debt accrues fastest where it hurts most, simply because that&amp;rsquo;s where the activation energy is highest.&lt;/p&gt;&#10;&lt;h2 id="what-was-meant-to-happen"&gt;What was meant to happen&#10;&lt;/h2&gt;&lt;p&gt;The review proposed two moves worth more than the rest put together. Re-pin the whole config family in one sweep, which clears two of the ecosystem findings at a stroke. And fleet-manage the CI component pin rather than nudging repos one at a time.&lt;/p&gt;&#10;&lt;p&gt;It also floated something I&amp;rsquo;ve been chewing on since: whether the config adapters ought to live in one multi-module repo with synchronised tagging. That would be a partial retreat from the thing &lt;a class="link" href="https://phpboyscout.uk/the-packages-that-didnt-want-to-leave/" &gt;I split up to begin with&lt;/a&gt;, and it might still be the right call.&lt;/p&gt;&#10;&lt;h2 id="where-it-stands-now"&gt;Where it stands now&#10;&lt;/h2&gt;&lt;p&gt;I checked, while writing this. There are sixty-seven modules, not fifty-one&amp;hellip; sixteen more mouths to feed than the review was even looking at. And the config family, which the review singled out as the worst fan-out in the estate, is clean: twenty-six adapters, all pinned to &lt;code&gt;config v0.15.0&lt;/code&gt;, which is the current core, with nothing stale and nothing sat three minors back hoping I don&amp;rsquo;t look. That&amp;rsquo;s the number I expected to be bad, and it&amp;rsquo;s the one that came back spotless (it didn&amp;rsquo;t get that way on its own).&lt;/p&gt;&#10;&lt;h2 id="getting-the-invoice-down"&gt;Getting the invoice down&#10;&lt;/h2&gt;&lt;p&gt;The cost was never the extraction, because you budget for the extraction. The cost is a recurring tax on every change afterwards, and the awkward part is that it scales with how &lt;em&gt;well&lt;/em&gt; you did the splitting. &lt;a class="link" href="https://phpboyscout.uk/the-repo-that-downloaded-170-gigabytes/" &gt;Smaller units of work&lt;/a&gt;, cleaner layering, a graph that actually reflects how the thing is built. And the price of all that is twelve releases for one line. Do it badly and you get a mess; do it properly and you get a bill.&lt;/p&gt;&#10;&lt;p&gt;So the question was never whether the architecture was worth it, it was how cheap I could make the tax. Three things, none of them finished. Renovate does the bumping, fleet-wide, from a single group-level bot rather than a config per repo. That turns &amp;ldquo;I remember to update twenty-six adapters&amp;rdquo; into something that happens whether I remember or not. It&amp;rsquo;s the reason all twenty-six are sat on the same core without me having chased a single one of them.&lt;/p&gt;&#10;&lt;p&gt;The pipelines do the gating. A release is a merge request like any other, so the same guards that stop a bad commit stop a bad release, and nothing needs a human except the button.&lt;/p&gt;&#10;&lt;p&gt;And a release-train skill, currently sat in review, which is the piece I&amp;rsquo;ve been missing. Releasing one repo is solved and has been for ages. Releasing twenty-six that depend on each other is a different problem, and it has a right answer that isn&amp;rsquo;t the obvious one. Cutting the leaves first &lt;em&gt;looks&lt;/em&gt; like it dodges a Renovate cycle:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Order&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Releases&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Leaves first: 26 adapters, then core, then Renovate bumps 26, then release again&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;&lt;strong&gt;53&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Upstream first: core, then Renovate propagates, then adapters absorb it and release once&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;&lt;strong&gt;27&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Cutting the leaves first doesn&amp;rsquo;t avoid the cycle. It defers it until after the releases, so every leaf gets cut a &lt;em&gt;second&lt;/em&gt; time the moment the core moves. Upstream-first costs you a wait while Renovate propagates, and nothing else. I had that backwards while driving the config family&amp;rsquo;s release train (for about an hour, which was long enough to feel it) and corrected myself before I doubled my own workload. That&amp;rsquo;s why it&amp;rsquo;s going into a skill rather than into my head: the mechanics are easy and the reasoning is what fails.&lt;/p&gt;&#10;&lt;h2 id="what-im-not-paying-for"&gt;What I&amp;rsquo;m not paying for&#10;&lt;/h2&gt;&lt;p&gt;There is, of course, a shop-bought answer to a chunk of this. GitLab has merge trains and they&amp;rsquo;d help, but they also live behind a subscription tier I&amp;rsquo;ve opened, looked at, and closed the tab on more than once. So I&amp;rsquo;m building my own out of Renovate, CI components and a skill that knows which way round to cut a release&amp;hellip; on the grounds that I&amp;rsquo;d rather spend the evenings than the money. Ask me again when I&amp;rsquo;ve made my millions.&lt;/p&gt;&#10;&lt;h2 id="sixty-seven-and-counting"&gt;Sixty-seven, and counting&#10;&lt;/h2&gt;&lt;p&gt;Fifty-one when the review was written, sixty-seven as I type this. That number going up is the estate &lt;em&gt;working&lt;/em&gt;: each of those is a thing you can use without taking the rest of it, which was the point of the exercise. There&amp;rsquo;ll be another one along next week, there always is, and the trick was never to stop adding them, it&amp;rsquo;s to get to the point where the sixty-eighth doesn&amp;rsquo;t cost me a Saturday.&lt;/p&gt;</content:encoded></item></channel></rss>