<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Provenance on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/provenance/</link><description>Recent content in Provenance on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Thu, 23 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/provenance/index.xml" rel="self" type="application/rss+xml"/><item><title>Everyone reads your config. This one writes it back.</title><link>https://phpboyscout.uk/everyone-reads-your-config/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/everyone-reads-your-config/</guid><description>&lt;img src="https://phpboyscout.uk/everyone-reads-your-config/cover-everyone-reads-your-config.png" alt="Featured image of post Everyone reads your config. This one writes it back." /&gt;&lt;p&gt;There&amp;rsquo;s a particular small betrayal I got tired of. You write a config file by hand. You leave a comment above the timeout explaining why it&amp;rsquo;s thirty seconds and not five, because the last person who dropped it took the site down. You group the keys the way that makes sense in your head. Then your tool changes one value, writes the file back out, and hands you an alphabetised slab of YAML with every comment gone&amp;hellip; and the database password that used to live in an environment variable now sitting in the file, committed to git.&lt;/p&gt;
&lt;p&gt;The value it changed is correct. Everything a person put there is gone.&lt;/p&gt;
&lt;p&gt;That isn&amp;rsquo;t a bug in anything. It&amp;rsquo;s what &amp;ldquo;serialise the merged view&amp;rdquo; means: fold every source into one map, and a writer holding that map has nothing to write back &lt;em&gt;but&lt;/em&gt; the whole of it. Most config libraries do exactly this, because reading is the job, and writing&amp;hellip; writing was never really the point.&lt;/p&gt;
&lt;p&gt;I know, because for years I was fine with it too.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t set out to build a config library. A long time ago I wrote a thin wrapper over &lt;a class="link" href="https://github.com/spf13/viper" target="_blank" rel="noopener"
 &gt;Viper&lt;/a&gt; to do one small thing it left to the caller: load a handful of config files and merge them in a defined order, so I wasn&amp;rsquo;t writing the same loop into every project. Viper did everything else, and did it well. My bit closed a gap you could measure in inches, and it worked well enough that it followed me from one project to the next for a very long time, barely changing shape. Viper did the work, my wrapper handled the loading, and that was that.&lt;/p&gt;
&lt;p&gt;Then the gaps stopped being measured in inches. When &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base" target="_blank" rel="noopener"
 &gt;go-tool-base&lt;/a&gt; came together and the config piece finally got some proper attention, I kept adding the things Viper had always left to me. Typed sections that stay current across a reload. Validation. Hot-reload that fails closed instead of swapping in a half-broken file the moment someone fat-fingers the YAML. Each one was a reasonable little workaround on its own. Stacked up, they&amp;rsquo;d turned into a description of a different component than the one I was wrapping.&lt;/p&gt;
&lt;p&gt;The thing that finally decided it was, of all things, a comment. Someone wanted a write that kept the file intact and touched only the key that owned it, and there&amp;rsquo;s no wrapping your way to that. The information you&amp;rsquo;d need is thrown out during the merge, long before anyone gets round to writing. Viper couldn&amp;rsquo;t give it to me, and neither could anything else, because they&amp;rsquo;d all made the same trade for the same sensible reason.&lt;/p&gt;
&lt;p&gt;So I stopped wrapping and built the thing underneath. It&amp;rsquo;s now a small family of modules, all public: &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config" target="_blank" rel="noopener"
 &gt;&lt;code&gt;config&lt;/code&gt;&lt;/a&gt;, and a growing set of adapters around it.&lt;/p&gt;
&lt;h2 id="the-file-is-a-document-not-a-dump"&gt;The file is a document, not a dump
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;config&lt;/code&gt; never folds its layers away. It keeps every source as its own layer, in precedence order, and a write lands in the layer that &lt;em&gt;owns&lt;/em&gt; the key you changed. So when you do this:&lt;/p&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;store&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;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.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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;it changes &lt;code&gt;server.port&lt;/code&gt;, and nothing else. The comment above it survives. The key order survives, the quoting, the anchors. The password that arrived from the environment stays in the environment, because the store knows that&amp;rsquo;s where it came from and doesn&amp;rsquo;t go pasting it into a file. If a change can&amp;rsquo;t actually take effect, because a higher layer is shadowing the key you aimed at, it tells you, rather than writing something that will never be read.&lt;/p&gt;
&lt;p&gt;This is the bit the rest of the field mostly doesn&amp;rsquo;t do. Viper is the one everyone reaches for, and its &lt;code&gt;WriteConfig&lt;/code&gt; marshals its internal map straight back to disk; its own maintainers will tell you it&amp;rsquo;s a dump, not an editor, and not meant for updating a file you care about. &lt;a class="link" href="https://github.com/knadh/koanf" target="_blank" rel="noopener"
 &gt;koanf&lt;/a&gt;, the cleaner, modern one I genuinely rate, simply doesn&amp;rsquo;t write at all&amp;hellip; a deliberate call to keep the library small, and a fair one. conflex has a dumper that re-serialises the merged map, same as Viper. Every one of them can put a value on disk. I haven&amp;rsquo;t found one that can do it without flattening the file a person actually wrote.&lt;/p&gt;
&lt;h2 id="where-the-value-came-from"&gt;Where the value came from
&lt;/h2&gt;&lt;p&gt;The other thing falls out of keeping the layers apart. When a value can be set by a default, a file, an environment variable and Consul, &amp;ldquo;which one won, and why?&amp;rdquo; stops being academic the first time you&amp;rsquo;re staring at the wrong value at two in the morning.&lt;/p&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;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;Explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;database.host&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// database.host = db-prod (from env:APP_DATABASE_HOST); also defined in /etc/app/config.yaml&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It&amp;rsquo;ll name the source that won &lt;em&gt;and&lt;/em&gt; the ones it beat. None of the readers can answer that, and it&amp;rsquo;s not a small omission on their part&amp;hellip; it&amp;rsquo;s structural. Once you&amp;rsquo;ve merged everything into one map, the map is all you have. The provenance was thrown away at the door. Because &lt;code&gt;config&lt;/code&gt; records it during the merge instead of reconstructing it after, the answer&amp;rsquo;s just there when you go looking.&lt;/p&gt;
&lt;h2 id="the-same-store-whatever-you-point-it-at"&gt;The same store, whatever you point it at
&lt;/h2&gt;&lt;p&gt;Everything so far is what &lt;code&gt;config&lt;/code&gt; does with a source. The other half is how many things get to &lt;em&gt;be&lt;/em&gt; one, and it&amp;rsquo;s where the little modules earn their keep. Three questions, and you only ever compile the answers you use.&lt;/p&gt;
&lt;p&gt;What format is it in? YAML out of the box; anything else through its own small adapter, &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config-json" target="_blank" rel="noopener"
 &gt;&lt;code&gt;config-json&lt;/code&gt;&lt;/a&gt;, &lt;code&gt;config-toml&lt;/code&gt;, &lt;code&gt;config-hcl&lt;/code&gt; and the rest, one module per format so a consumer who wants TOML pays for a TOML parser and nobody else does. That &amp;ldquo;a module per source&amp;rdquo; instinct isn&amp;rsquo;t mine alone, mind you&amp;hellip; it&amp;rsquo;s how koanf is built, and it&amp;rsquo;s the right shape.&lt;/p&gt;
&lt;p&gt;Where does the file live? On disk usually, and that&amp;rsquo;s built in. But it might be baked into the binary, sat on a box you reach over SSH, or in an S3 bucket, and each of those is just another filesystem &lt;code&gt;config&lt;/code&gt; reads and writes through an adapter, &lt;code&gt;config-sftp&lt;/code&gt;, &lt;code&gt;config-aws-s3&lt;/code&gt;, &lt;code&gt;config-gcp-gcs&lt;/code&gt; and the rest. The same store that rewrites one key in &lt;code&gt;/etc/app.yaml&lt;/code&gt; and leaves the comments around it alone does exactly that to a file living in an S3 bucket, and mostly doesn&amp;rsquo;t care about the difference.&lt;/p&gt;
&lt;p&gt;Or is it a file at all? &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config-consul" target="_blank" rel="noopener"
 &gt;&lt;code&gt;config-consul&lt;/code&gt;&lt;/a&gt; makes a Consul store an ordinary layer, with the same precedence and provenance a file gets, and behind it the cloud parameter stores: &lt;code&gt;config-aws-ssm&lt;/code&gt;, &lt;code&gt;config-azure-appconfig&lt;/code&gt;, &lt;code&gt;config-gcp-parameter&lt;/code&gt;. Everyone reads these; Viper reads Consul, koanf reads Vault. The part I haven&amp;rsquo;t seen elsewhere is that Consul &lt;em&gt;writes back&lt;/em&gt;: a change goes home as a compare-and-swap, so if the value moved under you since you read it, the write fails cleanly instead of stamping on whatever&amp;rsquo;s there now. The &amp;ldquo;keep the file yours&amp;rdquo; idea, pointed at a distributed key-value store.&lt;/p&gt;
&lt;p&gt;Secrets managers are next. But the shape&amp;rsquo;s set now: whatever your config is written in, wherever it lives, it&amp;rsquo;s the same layered store, and a two-way street.&lt;/p&gt;
&lt;h2 id="its-already-running"&gt;It&amp;rsquo;s already running
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t a library out looking for a first user. &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base" target="_blank" rel="noopener"
 &gt;go-tool-base&lt;/a&gt;, the toolkit all of this was pulled out of, runs on &lt;code&gt;config&lt;/code&gt; now and is finishing its own move off Viper. The framework it came from is the thing eating its own dog food, which is roughly how I like to find out whether something actually holds up before I ask anyone else to trust it.&lt;/p&gt;
&lt;p&gt;The docs, all of it, live at &lt;a class="link" href="https://config.go.phpboyscout.uk" target="_blank" rel="noopener"
 &gt;config.go.phpboyscout.uk&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It started because I was annoyed about a deleted comment. Turns out that annoyance was load-bearing.&lt;/p&gt;</description></item></channel></rss>