<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Showcases on PHP Boy Scout</title><link>https://phpboyscout.uk/showcase/</link><description>Recent content in Showcases on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><atom:link href="https://phpboyscout.uk/showcase/index.xml" rel="self" type="application/rss+xml"/><item><title>How I work</title><link>https://phpboyscout.uk/showcase/how-i-work/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/how-i-work/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Everything else on these pages was built by one person. There are 145 active projects in the estate, and AI agents do a great deal of the typing. That combination can go two ways. Either it&amp;rsquo;s a lot of plausible-looking code nobody really decided on, or it&amp;rsquo;s a lot of carefully decided code that happened to get typed quickly. The difference isn&amp;rsquo;t the agents, it&amp;rsquo;s the way of working around them, and that&amp;rsquo;s what this page is about.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s also the page I&amp;rsquo;d point a CTO at, because &amp;ldquo;how does one person keep 145 projects trustworthy?&amp;rdquo; is the question underneath every other showcase here.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="decide-it-in-writing-first"&gt;Decide it in writing first&#10;&lt;/h3&gt;&lt;p&gt;Nothing non-trivial gets built without a spec it implements. A spec records what done looks like, the decisions with what was rejected and what each one costs, and the questions still open, each one marked as either mine to answer or a fact an agent can go and establish. That split is what lets an agent work unattended overnight without quietly deciding things that were mine to decide. The specs live on each project&amp;rsquo;s wiki, and they&amp;rsquo;re public: go-tool-base alone has more than 200 of them. Every &amp;ldquo;decisions and what they cost&amp;rdquo; section on these showcases is lifted from them.&lt;/p&gt;&#10;&lt;h3 id="the-reasoning-stays-findable"&gt;The reasoning stays findable&#10;&lt;/h3&gt;&lt;p&gt;A decision is only useful if someone can find it later, so the specs are tied to the code and the docs from both ends. Code comments point at the decision that made them by number, and in go/errors&amp;rsquo; words, &amp;ldquo;a change contradicting a D-number moves the spec first&amp;rdquo;. Across the Go modules, more than a thousand comment lines cite a spec that way. Where a decision depends on a fact nobody has, a short timeboxed experiment settles it first, and the result is kept as a dated report the spec cites. The Go module wikis alone hold 97 of them. Rejected specs stay up too, with their reasons, so the same idea isn&amp;rsquo;t argued from scratch a year later.&lt;/p&gt;&#10;&lt;p&gt;The docs carry the other half. Nearly every module&amp;rsquo;s documentation has a page saying what it deliberately doesn&amp;rsquo;t do, written as a decision rather than an apology: &amp;ldquo;Each of these is a decision with a name, not a gap waiting to be filled&amp;rdquo; (&lt;a class="link" href="https://messaging.go.phpboyscout.uk/explanation/limitations/" target="_blank" rel="noopener"&#10; &gt;messaging&amp;rsquo;s limitations&lt;/a&gt;). &lt;a class="link" href="https://phpboyscout.uk/showcase/estate-architectural-patterns/" &gt;Estate architectural patterns&lt;/a&gt; is what all of that keeps producing.&lt;/p&gt;&#10;&lt;h3 id="one-session-per-project-and-the-tracker-between-them"&gt;One session per project, and the tracker between them&#10;&lt;/h3&gt;&lt;p&gt;Each project gets its own agent session, deliberately walled off so one can&amp;rsquo;t trample another&amp;rsquo;s work. When one turns up something another project needs, it doesn&amp;rsquo;t fix it in passing or try to remember it. It writes it up as an issue on that project, detailed enough that a session arriving with none of the context can act on it. &lt;a class="link" href="https://phpboyscout.uk/ready-for-human/" &gt;Ready for human&lt;/a&gt; is how that came about, after I spent a while being the message bus myself and getting it wrong.&lt;/p&gt;&#10;&lt;h3 id="every-review-is-a-claim-and-a-claim-gets-checked"&gt;Every review is a claim, and a claim gets checked&#10;&lt;/h3&gt;&lt;p&gt;An agent saying &amp;ldquo;fixed&amp;rdquo;, &amp;ldquo;tested&amp;rdquo; or &amp;ldquo;this doesn&amp;rsquo;t exist&amp;rdquo; is a claim, not a result. So a bug fix starts by proving the bug against the untouched code, with a failing test handed back before any change, because from the outside a real fix, a bug that never existed and a test written to pass all look the same (&lt;a class="link" href="https://phpboyscout.uk/prove-the-bug-before-you-fix-it/" &gt;Prove the bug before you fix it&lt;/a&gt;). Reviews get the same treatment: a design worth arguing about goes to a panel of models from different vendors, told not to agree with each other for the sake of it, and what they find is evidence to weigh rather than a verdict to accept. &lt;a class="link" href="https://phpboyscout.uk/showcase/how-its-tested/" &gt;How it&amp;rsquo;s tested&lt;/a&gt; covers what that looks like across the estate&amp;rsquo;s suites.&lt;/p&gt;&#10;&lt;h3 id="a-human-signs-for-every-line"&gt;A human signs for every line&#10;&lt;/h3&gt;&lt;p&gt;AI usage isn&amp;rsquo;t hidden anywhere here, and it isn&amp;rsquo;t credited in the commits either, because a person is accountable for every line that gets merged and an attribution trailer blurs who that is. Agents don&amp;rsquo;t cut releases. A release is a merge request that I merge, and on the infrastructure side even an apply waits on a human merging a plan that&amp;rsquo;s already been checked (&lt;a class="link" href="https://phpboyscout.uk/reviewed-then-applied/" &gt;Reviewed, then applied&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="the-habits-packaged"&gt;The habits, packaged&#10;&lt;/h3&gt;&lt;p&gt;All of this lives as skills that any agent session can load, in a &lt;a class="link" href="https://gitlab.com/phpboyscout/claude-code-plugins" target="_blank" rel="noopener"&#10; &gt;public plugin marketplace&lt;/a&gt;: 66 skills across 8 plugins at the last count, from spec-driven development and proving a bug first to timeboxing and running a review panel. Because a skill is effectively source code an agent runs, the marketplace has a security gate of its own on every change (&lt;a class="link" href="https://phpboyscout.uk/the-marketplace-i-had-to-defend-from-my-own-attack-surface/" &gt;the post on why&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Specs before code.&lt;/strong&gt; Every non-trivial change has a written decision record first. &lt;em&gt;What it cost:&lt;/em&gt; time up front on every feature, and a wiki per project to keep tidy, in exchange for agents that build what was decided instead of what sounded plausible.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Use, contribute, then build.&lt;/strong&gt; I&amp;rsquo;d rather use somebody else&amp;rsquo;s tool, and try to fix it upstream when it nearly fits, before writing my own (&lt;a class="link" href="https://phpboyscout.uk/building-it-yourself-is-the-third-thing-i-try/" &gt;Building it yourself is the third thing I try&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a lot of the estate is still my own anyway (colophon and go/chat are both third-rung builds), and each one is something I now maintain.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Isolation over convenience.&lt;/strong&gt; Separate sessions per project, with the issue tracker as the only channel between them. &lt;em&gt;What it cost:&lt;/em&gt; more issues than a team of one would normally write, and a handover has to be written as carefully as a spec.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;No AI attribution in commits.&lt;/strong&gt; Accountability stays with the person who merges. &lt;em&gt;What it cost:&lt;/em&gt; the agents&amp;rsquo; part shows up in the specs and reports, where it&amp;rsquo;s credited openly, rather than in the history.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;116 projects release through &lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt;, where merging a release is a human action by design.&lt;/li&gt;&#10;&lt;li&gt;The showcases themselves: &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt;, &lt;a class="link" href="https://phpboyscout.uk/showcase/chat/" &gt;chat&lt;/a&gt;, &lt;a class="link" href="https://phpboyscout.uk/showcase/signing-and-trust/" &gt;Signing and trust&lt;/a&gt; and colophon each cite the specs their decisions came from, and every one of those specs is public.&lt;/li&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/a-terrible-lead-to-an-ai-junior/" &gt;A terrible lead to an AI junior&lt;/a&gt; is the counterweight: the time the way of working failed, because I treated a problem as boilerplate and briefed it badly.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="borrow-it-when-and-when-not-to"&gt;Borrow it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Borrow it if you&amp;rsquo;re one person, or a small team, trying to get a lot done with agents without losing track of who decided what. The skills install into Claude Code, most of them carry Codex instructions too, and the general ones don&amp;rsquo;t assume my projects.&lt;/p&gt;&#10;&lt;p&gt;Don&amp;rsquo;t borrow all of it at once. The spec discipline pays for itself on anything you&amp;rsquo;ll still be maintaining in six months and is overkill for a weekend script, and the panel reviews cost real quota.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;The newest additions are a timebox that reads the real clock instead of an agent&amp;rsquo;s sense of time, and a research panel that runs one question past several models and makes them argue it out. Both shipped in October 2026, and both came out of finding the gap the hard way first.&lt;/p&gt;&#10;</description></item><item><title>Estate architectural patterns</title><link>https://phpboyscout.uk/showcase/estate-architectural-patterns/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/estate-architectural-patterns/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/how-i-work/" &gt;How I work&lt;/a&gt; is about the process: specs first, every claim checked, a human signing for every line. This page is about what that process keeps producing. &lt;a class="link" href="https://phpboyscout.uk/showcase/how-its-tested/" &gt;How it&amp;rsquo;s tested&lt;/a&gt; is the evidence behind both. Across around a hundred Go modules and the tools built on them, the same design rules turn up again and again, and they&amp;rsquo;re there because each one was learned the hard way at least once.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re deciding whether to depend on something here, these are the promises that hold across the whole estate rather than one module at a time. They fall into six groups: how modules are grouped into families, how each one is shaped, how it reports what happened, which way its defaults lean, what a tool is handed when it runs, and how the tools treat the people running them.&lt;/p&gt;&#10;&lt;h2 id="module-families-not-module-monoliths"&gt;Module families, not module monoliths&#10;&lt;/h2&gt;&lt;p&gt;The most obvious pattern in the estate is the family. Wherever several vendors do the same job (chat models, git forges, config stores, message brokers, key services), the work is split into a family rather than built as one module that knows about every vendor. A small core module holds the contract: the types, the behaviour every member has to have, and the tests that prove it. Each vendor, backend or file format then gets a member module of its own, which implements that contract and is the only place that vendor&amp;rsquo;s SDK is imported.&lt;/p&gt;&#10;&lt;p&gt;Eight families work this way, with 46 member modules between them. The biggest is &lt;a class="link" href="https://phpboyscout.uk/showcase/config/" &gt;config&lt;/a&gt;, with 26, and &lt;a class="link" href="https://phpboyscout.uk/showcase/chat/" &gt;chat&lt;/a&gt;, &lt;a class="link" href="https://phpboyscout.uk/showcase/git-and-forges/" &gt;git and forges&lt;/a&gt;, &lt;a class="link" href="https://phpboyscout.uk/showcase/messaging/" &gt;messaging&lt;/a&gt; and &lt;a class="link" href="https://phpboyscout.uk/showcase/signing-and-trust/" &gt;signing and trust&lt;/a&gt; each have a showcase of their own. A ninth, &lt;a class="link" href="https://gitlab.com/phpboyscout/go/dispatch" target="_blank" rel="noopener"&#10; &gt;go/dispatch&lt;/a&gt;, is being designed now, in public: a core for submitting and supervising multi-node training jobs on the scheduler a team already runs, with Slurm and Kubeflow as its first members. Its contract is already written in the same shape as the rest, with support reported three ways per cluster and a failure&amp;rsquo;s cause never collapsed into &amp;ldquo;failed&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/dispatch/-/wikis/specs/0001-the-core-contract" target="_blank" rel="noopener"&#10; &gt;dispatch spec 0001&lt;/a&gt;). Splitting things up this way is what the next three patterns grow out of.&lt;/p&gt;&#10;&lt;h3 id="a-program-carries-only-what-it-uses"&gt;A program carries only what it uses&#10;&lt;/h3&gt;&lt;p&gt;Most of the toolkit started life inside &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; and was lifted out into modules of its own, so a project can take the one piece it wants without the framework around it (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0119-go-module-extraction-playbook" target="_blank" rel="noopener"&#10; &gt;the extraction playbook, spec 0119&lt;/a&gt;). Families take that further. A program links only the members it imports, so a tool that only talks to Claude never carries the OpenAI or Gemini SDKs, and a tool that reads YAML never carries a cloud SDK.&lt;/p&gt;&#10;&lt;p&gt;The footprint is a design decision rather than a side effect, so it&amp;rsquo;s tested. Each module carries a test that lists everything the module pulls in and fails the build if a forbidden dependency turns up: the framework itself, its config and command-line libraries, telemetry or a cloud SDK. In the words of the controls module&amp;rsquo;s own test, a regression &amp;ldquo;fails here rather than silently bloating every downstream binary&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;It also makes what a binary can do a decision made in code, not in config. Each tool lists the members it links in one file, deleting a line takes that SDK out of the build, and nothing at runtime can put it back. In some families importing the member is all it takes to switch it on (chat, forge, comms, signing and encryption), and in others you construct it yourself (config and messaging). A linked plugin is never switched on by default either, because otherwise &amp;ldquo;an import list becomes a behavioural file&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/2c60322/pkg/props/feature_registry.go#L108-114" target="_blank" rel="noopener"&#10; &gt;go-tool-base&lt;/a&gt;). Moving the adapters out of go-tool-base&amp;rsquo;s own packages took a binary that uses none of them from 81 MB to 34 MB (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0194-the-gtb-cli-as-a-nested-module-and-adapters-chosen-per-tool" target="_blank" rel="noopener"&#10; &gt;spec 0194&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; a member that isn&amp;rsquo;t linked fails at runtime, and a missing import is the first thing to check.&lt;/p&gt;&#10;&lt;h3 id="one-contract-proven-on-every-member"&gt;One contract, proven on every member&#10;&lt;/h3&gt;&lt;p&gt;Because every member implements the same contract, the core can ship one shared test suite that every member runs unchanged. The suite is itself tested against deliberately broken backends, and every one of them has to fail. The forge suite&amp;rsquo;s reason: &amp;ldquo;A guard that passes everything is worse than no guard, because it certifies nothing while looking like assurance.&amp;rdquo; Messaging runs five named lies, including a backend that claims to survive a restart and doesn&amp;rsquo;t (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/messaging/-/wikis/specs/0003-the-second-backend" target="_blank" rel="noopener"&#10; &gt;spec 0003&lt;/a&gt;). Every forge and messaging member runs one, as do the chat providers (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0011-chat-provider-conformance" target="_blank" rel="noopener"&#10; &gt;spec 0011&lt;/a&gt;), comms, and config&amp;rsquo;s storage backends. A new vendor proves itself by passing the same suite as the ones already there.&lt;/p&gt;&#10;&lt;p&gt;The fakes behind each member&amp;rsquo;s unit tests are checked against the real service too, by a suite that runs in a container when asked for. A fake &amp;ldquo;encodes those same assumptions&amp;rdquo; as the code it stands in for, and &amp;ldquo;These tests are what stop that being circular&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/config-vault/-/blob/7c626b2/test/integration/vault_test.go#L7-12" target="_blank" rel="noopener"&#10; &gt;config-vault&lt;/a&gt;). &lt;a class="link" href="https://phpboyscout.uk/showcase/how-its-tested/" &gt;How it&amp;rsquo;s tested&lt;/a&gt; covers both kinds of suite, and the rest of the estate&amp;rsquo;s testing. &lt;em&gt;What it costs:&lt;/em&gt; Docker in CI for the container suites, and the container library appears in fourteen repositories&amp;rsquo; requirement lists, though never in a consumer&amp;rsquo;s binary.&lt;/p&gt;&#10;&lt;h3 id="a-core-release-reaches-every-member"&gt;A core release reaches every member&#10;&lt;/h3&gt;&lt;p&gt;Members aren&amp;rsquo;t pinned to each other or to a matching version. Each one releases on its own schedule, for its own fixes and features, so chat&amp;rsquo;s providers and config&amp;rsquo;s adapters all sit at different versions. What the family does guarantee is that a change to the core reaches all of them. When a core releases, the bump lands on every member as a fix, usually opened by Renovate, so &lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt; cuts a new release of each one, and the whole family is out on the new core together. A check on every member&amp;rsquo;s release merge request flags one that would ship against an older core (&lt;a class="link" href="https://phpboyscout.uk/showcase/the-pipeline/" &gt;the pipeline&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; every core release becomes a release of every member, so config&amp;rsquo;s 26 adapters each release again whenever the config core does.&lt;/p&gt;&#10;&lt;h2 id="the-shape-of-a-module"&gt;The shape of a module&#10;&lt;/h2&gt;&lt;h3 id="settings-in-no-configuration-reading"&gt;Settings in, no configuration reading&#10;&lt;/h3&gt;&lt;p&gt;A toolkit module takes plain settings, as a struct or as options applied over safe defaults, and the application maps its own configuration onto them. Not one module outside the config family imports config. Messaging&amp;rsquo;s limitations page gives the reason: &amp;ldquo;A toolkit module that reads configuration decides for its consumer where configuration comes from&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0115-config-section-adapters-for-extraction" target="_blank" rel="noopener"&#10; &gt;typed config section adapters, spec 0115&lt;/a&gt;). Secrets arrive the same way, as a function the module calls when it needs one, never a stored string. &lt;a class="link" href="https://phpboyscout.uk/showcase/secrets-in-use/" &gt;Secrets in use&lt;/a&gt; covers that half.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; every application writes the mapping.&lt;/p&gt;&#10;&lt;h3 id="the-standard-library-at-every-boundary"&gt;The standard library at every boundary&#10;&lt;/h3&gt;&lt;p&gt;Logging, HTTP and the filesystem come in as the standard library&amp;rsquo;s own types: Go&amp;rsquo;s structured logger, its HTTP client, and one common filesystem interface. A module that logs nothing when you hand it nothing never writes to the global logger. The repo module&amp;rsquo;s docs give the history: an earlier logging dependency &amp;ldquo;pulled an entire terminal-UI stack transitively&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0116-slog-first-extraction-seams" target="_blank" rel="noopener"&#10; &gt;logging through the standard library, spec 0116&lt;/a&gt;). No toolkit module imports a third-party logging library. The tools built on go-tool-base use a terminal-friendly backend, but behind go-tool-base&amp;rsquo;s own logger, which hands the standard library&amp;rsquo;s handler to anything else that asks.&lt;/p&gt;&#10;&lt;h3 id="depend-on-the-narrowest-piece"&gt;Depend on the narrowest piece&#10;&lt;/h3&gt;&lt;p&gt;Modules expose small interfaces for each role, and a consumer depends on the smallest one that covers what it touches. &lt;a class="link" href="https://repo.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/repo&lt;/a&gt; has ten, from reading a tree to tagging. Adapters declare their own one-method slice of the vendor client they wrap, so a test can drive them with a fake instead of a cloud account. &lt;em&gt;What it costs:&lt;/em&gt; adding a method to a published interface breaks every consumer&amp;rsquo;s hand-written fake. go/repo found none of seven consumers checked their mocks against it (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/repo/-/wikis/specs/0002-mock-interface-conformance" target="_blank" rel="noopener"&#10; &gt;spec 0002&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="errors-that-say-what-to-do"&gt;Errors that say what to do&#10;&lt;/h3&gt;&lt;p&gt;The estate owns its error package, &lt;a class="link" href="https://errors.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/errors&lt;/a&gt;, with no dependencies at all. It replaced a library that cost 46 transitive packages for the ten functions in use (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/errors/-/wikis/specs/0001-an-errors-package-we-own" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;). The convention it exists to carry is that the fix goes in the error, as a hint the user sees. A forge credential that&amp;rsquo;s configured the old way says so, and points at the migration table, rather than surfacing as a 401 far from the cause. 92 Go repositories require it, and none still carries the old library.&lt;/p&gt;&#10;&lt;p&gt;Creating an error and reporting it are kept apart. Deep code adds the context and the hint and returns the error. It never decides whether the program should stop. One place near the top does, with the exit code travelling on the error itself, so there&amp;rsquo;s &amp;ldquo;one exit path&amp;rdquo; in the whole program (&lt;a class="link" href="https://errorhandling.go.phpboyscout.uk/explanation/reporting-model/" target="_blank" rel="noopener"&#10; &gt;the reporting model&lt;/a&gt;). &lt;a class="link" href="https://phpboyscout.uk/showcase/errors/" &gt;Errors&lt;/a&gt; covers creating and reporting in more depth.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s one of several dependencies the estate took over once their upkeep had stalled, each with the evidence written down first. colophon replaced a release tool that &amp;ldquo;has had two human commits in its last sixty&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/colophon/-/wikis/specs/0001-a-release-orchestrator-we-own" target="_blank" rel="noopener"&#10; &gt;colophon spec 0001&lt;/a&gt;), and yamldoc became a YAML engine of its own (&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;).&lt;/p&gt;&#10;&lt;h2 id="honest-outcomes"&gt;Honest outcomes&#10;&lt;/h2&gt;&lt;h3 id="dont-know-is-an-answer"&gt;&amp;ldquo;Don&amp;rsquo;t know&amp;rdquo; is an answer&#10;&lt;/h3&gt;&lt;p&gt;Capabilities are three-valued: yes, no, or unknown. A provider that can&amp;rsquo;t say whether a model supports tools reports unknown, never no, because the two-valued alternative &amp;ldquo;is a lie which suppresses working features&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0006-capability-reporting" target="_blank" rel="noopener"&#10; &gt;chat spec 0006&lt;/a&gt;). Only a confident no turns a feature off, and there&amp;rsquo;s deliberately no shortcut that squashes the three into a yes or no. The same shape is used for forge sites and comms channels. &lt;em&gt;What it costs:&lt;/em&gt; every caller handles three cases.&lt;/p&gt;&#10;&lt;h3 id="bounded-by-default-and-loss-is-counted"&gt;Bounded by default, and loss is counted&#10;&lt;/h3&gt;&lt;p&gt;Queues, reads, caches and waits all have a limit, and where something is left unbounded on purpose, the code says why. Going over a limit is refused rather than trimmed to fit, because &amp;ldquo;a clamp changes what the job means without saying so&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/wikis/specs/0044-the-engine-must-finish" target="_blank" rel="noopener"&#10; &gt;afmpeg spec 0044&lt;/a&gt;). Whatever is dropped at a full queue is counted against the subscription it happened to. Messaging&amp;rsquo;s reason: &amp;ldquo;An unbounded queue is not a policy. It is a decision to fail once memory is gone rather than at a number somebody chose&amp;rdquo; (&lt;a class="link" href="https://messaging.go.phpboyscout.uk/explanation/full-queue/" target="_blank" rel="noopener"&#10; &gt;full queues&lt;/a&gt;). Limits like these are in 25 Go modules, from messaging and NATS to the HTTP client, the rate limiter and every capped read.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; swapping a messaging backend can change what happens at a full queue, because each backend can only drop messages the ways it supports. And a call that can&amp;rsquo;t be cancelled, such as a keychain write, is bounded by giving up on it, so it may still finish after you&amp;rsquo;ve moved on.&lt;/p&gt;&#10;&lt;h3 id="partial-success-says-what-it-dropped"&gt;Partial success says what it dropped&#10;&lt;/h3&gt;&lt;p&gt;When part of a request can&amp;rsquo;t be honoured, you still get a working result, along with an error listing exactly what wasn&amp;rsquo;t applied. chat&amp;rsquo;s constructor &amp;ldquo;returns the best functional client it can build, alongside an error describing everything it could not apply&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0007-degraded-construction" target="_blank" rel="noopener"&#10; &gt;chat spec 0007&lt;/a&gt;). The forge adapters do the same for a label that didn&amp;rsquo;t stick, and their error &amp;ldquo;never travels alone&amp;rdquo;, so finding it is a complete test for &amp;ldquo;this partly succeeded&amp;rdquo;. Messaging falls back to a backend&amp;rsquo;s own full-queue behaviour and says so, and the schema guard lets a message it can&amp;rsquo;t check through, but counts it (&lt;a class="link" href="https://schema.go.phpboyscout.uk/explanation/failing-open/" target="_blank" rel="noopener"&#10; &gt;failing open&lt;/a&gt;). Anything the module is responsible for itself still fails outright.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; Go&amp;rsquo;s habit is to ignore the result whenever there&amp;rsquo;s an error, and a caller who does that gets the old all-or-nothing behaviour. The better one is there for callers who ask for it.&lt;/p&gt;&#10;&lt;h3 id="silence-is-a-failure"&gt;Silence is a failure&#10;&lt;/h3&gt;&lt;p&gt;A green run has to mean the work happened. A skipped test, a tag whose release never appeared, a message delivered to nobody: each is turned into something visible, because &amp;ldquo;an absence does not show up in a log&amp;rdquo; (&lt;a class="link" href="https://cicd.phpboyscout.uk/explanation/a-skip-is-not-a-pass/" target="_blank" rel="noopener"&#10; &gt;a skip is not a pass&lt;/a&gt;). When one project&amp;rsquo;s skipped tests started running, its coverage went from 52% to 91%. colophon warns about a tag with no release (&lt;a class="link" href="https://gitlab.com/phpboyscout/colophon/-/wikis/specs/0018-notice-a-tag-whose-release-never-appeared" target="_blank" rel="noopener"&#10; &gt;spec 0018&lt;/a&gt;), and the message bus counts a publish nobody received.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; time and noise. That same project&amp;rsquo;s tests went from 1.8 seconds to 12.3, and making image tool bumps visible meant more tags and changelog entries across ten repositories (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0081-a-tool-bump-that-changes-an-image-must-release-it" target="_blank" rel="noopener"&#10; &gt;cicd spec 0081&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="never-cache-a-failure"&gt;Never cache a failure&#10;&lt;/h3&gt;&lt;p&gt;Clients for AWS, Azure, Google Cloud and Vault are built once and shared, through one helper that keeps a success and forgets a failure, so the next call tries again. Caching the failure would turn a passing network blip into an outage that lasts until the program restarts, which is why Go&amp;rsquo;s own build-once helper &amp;ldquo;is explicitly not an acceptable implementation of this contract anywhere in the estate&amp;rdquo; (&lt;a class="link" href="https://clientlifecycle.go.phpboyscout.uk/explanation/never-cache-a-failure/" target="_blank" rel="noopener"&#10; &gt;never cache a failure&lt;/a&gt;). 17 modules use it. &lt;em&gt;What it costs:&lt;/em&gt; while a provider is down, every call pays for another attempt.&lt;/p&gt;&#10;&lt;h2 id="safe-by-default"&gt;Safe by default&#10;&lt;/h2&gt;&lt;h3 id="opt-out-of-safety-never-in"&gt;Opt out of safety, never in&#10;&lt;/h3&gt;&lt;p&gt;The constructor with no options is the hardened one. The HTTP client insists on TLS 1.2 or later, caps its connection pool and refuses a redirect from https to http, and &amp;ldquo;You opt &lt;em&gt;out&lt;/em&gt; of these … never in&amp;rdquo; (&lt;a class="link" href="https://httpclient.go.phpboyscout.uk/explanation/hardened-defaults/" target="_blank" rel="noopener"&#10; &gt;hardened defaults&lt;/a&gt;). transit retries only requests that are safe to repeat, because &amp;ldquo;the safe direction has to be the quiet one&amp;rdquo; (&lt;a class="link" href="https://transit.go.phpboyscout.uk/explanation/safe-defaults/" target="_blank" rel="noopener"&#10; &gt;safe defaults&lt;/a&gt;). forge sends a credential only to the host it was configured for.&lt;/p&gt;&#10;&lt;p&gt;The same goes for anything that reaches a person or leaves the machine. Telemetry, posting, mentions, uploads and AI providers are all off until asked for. afmpeg caps a job&amp;rsquo;s memory and time out of the box, because &amp;ldquo;A library whose safe configuration is opt-in gets deployed unsafely&amp;rdquo; (&lt;a class="link" href="https://afmpeg.phpboyscout.uk/explanation/concepts/safe-defaults/" target="_blank" rel="noopener"&#10; &gt;safe defaults&lt;/a&gt;), and krites sends no photograph anywhere unasked, since &amp;ldquo;A default that silently uploaded photographs would be a betrayal, not a convenience&amp;rdquo; (&lt;a class="link" href="https://krites.phpboyscout.uk/explanation/concepts/why-providers-are-opt-in/" target="_blank" rel="noopener"&#10; &gt;why providers are opt-in&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; the safe default sometimes gets in the way. The HTTP client speaks HTTP/1.1 only, and because its list of key-exchange curves is pinned, it doesn&amp;rsquo;t offer the hybrid post-quantum ones Go adds to its own defaults. An afmpeg job that needs more than 512 MB fails until someone raises the limit.&lt;/p&gt;&#10;&lt;h3 id="refuse-rather-than-ignore-or-guess"&gt;Refuse, rather than ignore or guess&#10;&lt;/h3&gt;&lt;p&gt;A setting the module can&amp;rsquo;t honour safely is refused when it&amp;rsquo;s built or loaded, with an error that names it. It&amp;rsquo;s never dropped or guessed at. The embedded NATS server won&amp;rsquo;t listen on a network until it has TLS and authentication, because &amp;ldquo;the unsafe surface does not exist rather than existing with a warning next to it&amp;rdquo;. The NATS client refuses a URL with a password in it, and refuses to send a token over an unencrypted connection. Config refuses to write a value from a secrets backend into a plain file, and refuses an HCL file that uses variables or functions. Signing refuses a key format it would otherwise have misread. Code that parses input from outside is fuzzed for the property it protects, not just for crashes: one encryption target checks that bytes appended to a published certificate can never change the key a report gets encrypted to (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/encryption/-/blob/cf887f3/certificate/fuzz_test.go#L98-109" target="_blank" rel="noopener"&#10; &gt;go/encryption&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;When a decision about trust or visibility has to be made without an answer, the restrictive branch wins. A forge that can&amp;rsquo;t tell whether a repository is public reports unknown, and callers treat unknown as not public, because there a wrong answer &amp;ldquo;is a disclosure, not a bug&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0003-namespace-discovery-capabilities" target="_blank" rel="noopener"&#10; &gt;forge spec 0003&lt;/a&gt;). There&amp;rsquo;s one deliberate exception. Self-update goes ahead on the key built into the binary when the published copy can&amp;rsquo;t be reached, so a tool behind a broken proxy can still patch itself, though two copies that disagree always stop it. krites&amp;rsquo; model downloads refuse in the same situation, because for them an unreachable key host &amp;ldquo;means try again later&amp;rdquo;. The stakes decide.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; capability withheld until it&amp;rsquo;s safe, which is a real gap for anyone who wanted it now.&lt;/p&gt;&#10;&lt;h3 id="lifecycles-that-cant-be-revived"&gt;Lifecycles that can&amp;rsquo;t be revived&#10;&lt;/h3&gt;&lt;p&gt;Two rules from &lt;a class="link" href="https://phpboyscout.uk/showcase/controls/" &gt;controls&lt;/a&gt;. A registered service is required, so one that&amp;rsquo;s unready makes the whole program unready, while a worker attached to a supervisor is watched but not required (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/controls/-/wikis/specs/0002-supervision-for-children-that-come-and-go" target="_blank" rel="noopener"&#10; &gt;spec 0002&lt;/a&gt;). And anything that can only be used once, like a listener or a server, is rebuilt from scratch on every restart, never revived. A stopped copy refuses to be used again (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/controls/-/wikis/specs/0004-a-generation-is-the-unit-a-restart-replaces" target="_blank" rel="noopener"&#10; &gt;spec 0004&lt;/a&gt;). That rule came from five real failures across three modules, and controls ships a generic wrapper for rebuilding on restart, plus an analyzer that names the line where a service holds on to something it didn&amp;rsquo;t build.&lt;/p&gt;&#10;&lt;h2 id="props-one-typed-context-for-what-a-tool-needs"&gt;Props: one typed context for what a tool needs&#10;&lt;/h2&gt;&lt;p&gt;Every command in a tool built on &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; is handed the same thing: Props, a single object holding what a command might need from its surroundings. The name isn&amp;rsquo;t short for &amp;ldquo;properties&amp;rdquo;. A prop is the beam that stops a structure falling down (&lt;a class="link" href="https://gtb.phpboyscout.uk/explanation/components/props/" target="_blank" rel="noopener"&#10; &gt;Props&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;In pattern terms, Props is a strictly typed encapsulated context object: one object carrying the services a unit of work needs, passed down instead of a long list of parameters. It&amp;rsquo;s built by hand in &lt;code&gt;main&lt;/code&gt;, so it isn&amp;rsquo;t an IoC container. Nothing is registered, resolved or built for you, and what goes in is exactly what &lt;code&gt;main&lt;/code&gt; put there.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s deliberately not Go&amp;rsquo;s own &lt;code&gt;context.Context&lt;/code&gt;. A context carries values as untyped keys, so a command that wants the logger has to look it up, cast it and hope, and a missing one only shows up when that line runs. Props has a typed field for each service, so asking for the logger gets a logger, checked by the compiler, and every dependency a tool has is listed in one struct you can read. In go-tool-base&amp;rsquo;s own words, &amp;ldquo;Context is for cancellation and deadlines, not dependency wiring&amp;rdquo; (&lt;a class="link" href="https://gtb.phpboyscout.uk/development/feature-decisions/" target="_blank" rel="noopener"&#10; &gt;architectural decisions&lt;/a&gt;), so the two travel side by side and each does one job.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s a deliberate &amp;ldquo;god object&amp;rdquo;, and a function that takes the whole of it would hide which services it really uses, the way a service locator does. The &lt;a class="link" href="#depend-on-the-narrowest-piece" &gt;narrow interfaces&lt;/a&gt; are what stop that: a function that only logs asks for something that can log, not for all of Props. &lt;a class="link" href="https://phpboyscout.uk/showcase/rust-tool-base/" &gt;rust-tool-base&lt;/a&gt; reaches the same outcome with a generic application type and strongly typed config instead (&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0008-rust-tool-base" target="_blank" rel="noopener"&#10; &gt;spec 0008&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="a-filesystem-you-can-swap"&gt;A filesystem you can swap&#10;&lt;/h3&gt;&lt;p&gt;Commands never touch the disk directly. They go through a filesystem on Props, which is the real disk in production and an in-memory one under test, and can be layered, for instance a read-only disk with changes held in memory on top. The test helper builds a complete Props with no real filesystem, network, keychain or process exit behind it, so tests that use it are safe to run in parallel.&lt;/p&gt;&#10;&lt;h3 id="assets-that-merge"&gt;Assets that merge&#10;&lt;/h3&gt;&lt;p&gt;A tool ships its defaults, templates and docs inside the binary, so it works the moment it&amp;rsquo;s installed. Each command registers its own embedded files into the shared asset store, and opening a file reads across all of them. A plain file is taken from whichever command registered last. A structured file (YAML, JSON, TOML, CSV, &lt;code&gt;.env&lt;/code&gt;) is merged from every copy, so a feature can add its own settings to the shared defaults without editing anyone else&amp;rsquo;s (&lt;a class="link" href="https://gtb.phpboyscout.uk/explanation/components/assets/" target="_blank" rel="noopener"&#10; &gt;asset management&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="logging-and-telemetry-that-are-always-safe-to-call"&gt;Logging and telemetry that are always safe to call&#10;&lt;/h3&gt;&lt;p&gt;The logger and the log level are on Props, and &lt;code&gt;--debug&lt;/code&gt; moves the level for everything at once. The telemetry collector is never empty: when telemetry is off, it&amp;rsquo;s a stand-in that does nothing, so a command records an event without first checking whether anyone is listening. What that telemetry may collect depends on whose data it is. Usage analytics is &amp;ldquo;the vendor learning about users and runs on &lt;strong&gt;informed consent&lt;/strong&gt;&amp;rdquo;, so it&amp;rsquo;s off until the user opts in. Observability, meaning traces, metrics and logs for a service, is &amp;ldquo;the operator instrumenting their own service&amp;rdquo;, so configuring it is consent enough (&lt;a class="link" href="https://gtb.phpboyscout.uk/explanation/components/observability/" target="_blank" rel="noopener"&#10; &gt;observability&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="the-invocations-own-streams"&gt;The invocation&amp;rsquo;s own streams&#10;&lt;/h3&gt;&lt;p&gt;Input, output and whether a person is at the keyboard come from Props too, not straight from the process. No package in the framework names the process&amp;rsquo;s own standard input or output, and a test enforces that. So a test drives a real setup wizard just by handing it streams, and a tool can redirect its terminal without the framework noticing.&lt;/p&gt;&#10;&lt;h3 id="whats-deliberately-left-out"&gt;What&amp;rsquo;s deliberately left out&#10;&lt;/h3&gt;&lt;p&gt;Props carries no database. go-tool-base&amp;rsquo;s decision record turns one down because &amp;ldquo;Different tools need different data stores: imposing a database pattern would pull GTB into application framework territory&amp;rdquo; (&lt;a class="link" href="https://gtb.phpboyscout.uk/development/feature-decisions/" target="_blank" rel="noopener"&#10; &gt;architectural decisions&lt;/a&gt;). An event bus, a task queue and a caching layer were turned down for the same reason: the framework is a foundation for tools, not an application framework.&lt;/p&gt;&#10;&lt;p&gt;So each tool picks its own storage, and the estate&amp;rsquo;s default is the user&amp;rsquo;s own files. keryx keeps its work in files committed to git, and krites keeps its records in files beside the photographs. Where a tool really needs a database, it has the one that suits it: phpbotscout, a bot that runs all the time, uses SQLite, and &lt;a class="link" href="https://scoutdm.com/" target="_blank" rel="noopener"&#10; &gt;Scout.DM&lt;/a&gt; uses Postgres.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; a container this broad is easy to lean on for everything, which is why the narrow interfaces exist. And a tool that needs a database wires its own, with nothing from the framework to start from.&lt;/p&gt;&#10;&lt;h2 id="tools-people-run"&gt;Tools people run&#10;&lt;/h2&gt;&lt;h3 id="one-engine-several-faces"&gt;One engine, several faces&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/keryx/" &gt;keryx&lt;/a&gt; and &lt;a class="link" href="https://phpboyscout.uk/showcase/krites/" &gt;krites&lt;/a&gt; each have a command line, a local web studio and an MCP surface for agents, and all three sit on the same code and the same files. keryx&amp;rsquo;s studio &amp;ldquo;maps 1:1 to a CLI operation on the same files&amp;rdquo; and &amp;ldquo;does not introduce a second source of truth&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/wikis/specs/0011-studio" target="_blank" rel="noopener"&#10; &gt;keryx spec 0011&lt;/a&gt;). krites requires that its studio &amp;ldquo;exposes nothing the CLI/engine can&amp;rsquo;t do&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/wikis/specs/0003-studio" target="_blank" rel="noopener"&#10; &gt;krites spec 0003&lt;/a&gt;). go-tool-base&amp;rsquo;s MCP mode runs every tool call as a command of the same binary, so an agent and a person get the same behaviour.&lt;/p&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://scoutdm.com/" target="_blank" rel="noopener"&#10; &gt;Scout.DM&lt;/a&gt; takes it a step further. Its faces are roles, named in config: the chat platform connection, voice capture, the machine-facing operator API, the operator console and the web portal. One process runs all of them by default, which suits a single container. A deployment can instead start them separately, as a set of services that each run some of the roles and reach each other over a NATS message bus. It&amp;rsquo;s the same binary either way, because &amp;ldquo;a role boundary that exists only in production is a role boundary nobody tests&amp;rdquo;. A role name it doesn&amp;rsquo;t recognise is refused at start, since a process that starts and runs nothing looks exactly like a healthy idle one.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; the studio is built into the binary by a generate step, so a plain &lt;code&gt;go install&lt;/code&gt; of krites ships a placeholder in its place.&lt;/p&gt;&#10;&lt;h3 id="irreversible-verbs-stay-human"&gt;Irreversible verbs stay human&#10;&lt;/h3&gt;&lt;p&gt;Agents get the commands that read, draft and generate. Anything that publishes, signs, spends or touches credentials is kept off the MCP surface &amp;ldquo;so an assistant cannot post publicly or rotate tokens&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/wikis/specs/0055-cli-mcp-contracts" target="_blank" rel="noopener"&#10; &gt;keryx spec 0055&lt;/a&gt;). sigillum keeps signing back for the same reason: &amp;ldquo;a signature, once emitted, is a claim the project cannot retract&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/sigillum/-/wikis/specs/0003-mcp-tool-surface" target="_blank" rel="noopener"&#10; &gt;sigillum spec 0003&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;Where something runs unattended, it acts only on an approval a person has recorded in git. keryx&amp;rsquo;s scheduled posts send only what was approved, because &amp;ldquo;Approval is the point where a person takes responsibility&amp;rdquo; (&lt;a class="link" href="https://keryx.phpboyscout.uk/explanation/concepts/" target="_blank" rel="noopener"&#10; &gt;keryx concepts&lt;/a&gt;). &lt;a class="link" href="https://phpboyscout.uk/showcase/phpbotscout/" &gt;phpbotscout&lt;/a&gt; starts on every server in shadow mode, where &amp;ldquo;Everything runs; nothing posts&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/wikis/specs/0010-discord-answering" target="_blank" rel="noopener"&#10; &gt;phpbotscout spec 0010&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; a person stays in the loop for every one of those steps, and phpbotscout posts only after a person has read 30 of its answers and agreed they&amp;rsquo;re worth posting. Even then the software doesn&amp;rsquo;t switch itself on, because &amp;ldquo;nothing here lifts shadow mode&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/blob/8dc21db/pkg/review/review.go#L1-7" target="_blank" rel="noopener"&#10; &gt;phpbotscout&lt;/a&gt;). A person does.&lt;/p&gt;&#10;&lt;h3 id="ask-the-world-and-a-re-run-finishes-the-job"&gt;Ask the world, and a re-run finishes the job&#10;&lt;/h3&gt;&lt;p&gt;A tool&amp;rsquo;s own files are the default store (&lt;a class="link" href="#whats-deliberately-left-out" &gt;what&amp;rsquo;s deliberately left out&lt;/a&gt; covers why, and the exceptions). Where a tool needs to know what happened, it asks the thing itself rather than a record of it. colophon takes versions from git, &amp;ldquo;the only source that cannot disagree with reality&amp;rdquo;, after finding 9 of 231 recent tags in the Go group weren&amp;rsquo;t on their default branch (&lt;a class="link" href="https://colophon.phpboyscout.uk/explanation/concepts/what-actually-landed/" target="_blank" rel="noopener"&#10; &gt;what actually landed&lt;/a&gt;). &lt;a class="link" href="https://skillup.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;skillup&lt;/a&gt; is built on the same idea. It takes a plugin&amp;rsquo;s previous version from the history of the plugin&amp;rsquo;s own manifest rather than from the last tag, because &amp;ldquo;A marker is a second source of truth, and a second source of truth is something that can disagree with the first&amp;rdquo; (&lt;a class="link" href="https://skillup.phpboyscout.uk/explanation/why-not-tags/" target="_blank" rel="noopener"&#10; &gt;why not tags&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;That makes a re-run safe. colophon &amp;ldquo;finishes the release it started instead of adding a second one beside it, and says which of the two happened&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/colophon/-/wikis/specs/0014-finish-a-stranded-draft" target="_blank" rel="noopener"&#10; &gt;colophon spec 0014&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;What it costs:&lt;/em&gt; a read before every write. And keryx keeps large media in object storage rather than git, which handles it badly.&lt;/p&gt;&#10;&lt;h3 id="a-preview-that-writes-nothing"&gt;A preview that writes nothing&#10;&lt;/h3&gt;&lt;p&gt;Deciding, writing and pushing are separate commands, and the one that decides writes nothing and needs no credentials. colophon&amp;rsquo;s &lt;code&gt;plan&lt;/code&gt; &amp;ldquo;Writes nothing: no branch, no merge request, no tag. Needs no credentials, so it is safe to run against any checkout&amp;rdquo; (&lt;a class="link" href="https://colophon.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;colophon&lt;/a&gt;). skillup&amp;rsquo;s &lt;code&gt;plan&lt;/code&gt; changes nothing and its &lt;code&gt;apply&lt;/code&gt; writes one file without committing, keryx has a dry run on its writing commands, and config plans a change before applying it. &lt;em&gt;What it costs:&lt;/em&gt; more commands to learn.&lt;/p&gt;&#10;&lt;h3 id="the-irreversible-step-goes-last"&gt;The irreversible step goes last&#10;&lt;/h3&gt;&lt;p&gt;Every step that can fail runs before the one that can&amp;rsquo;t be undone. &lt;a class="link" href="https://phpboyscout.uk/showcase/rust-tool-base/" &gt;rust-tool-base&lt;/a&gt;&amp;rsquo;s updater downloads, checks the signature and checksum, unpacks and runs the new binary&amp;rsquo;s self-test before swapping it in, so &amp;ldquo;the disk holds either the old version or the fully verified new one&amp;rdquo;. colophon creates the release last, because a release with nothing on it &amp;ldquo;answers &lt;em&gt;yes&lt;/em&gt; to the only question a consumer knows how to ask&amp;rdquo;, and two live breakages were recorded before that was fixed (&lt;a class="link" href="https://colophon.phpboyscout.uk/explanation/concepts/complete-releases/" target="_blank" rel="noopener"&#10; &gt;complete releases&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;And once released, nothing changes: &amp;ldquo;A broken release is corrected by cutting a new tag&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/colophon/-/wikis/specs/0025-release-binaries-in-object-storage-on-a-vanity-URL" target="_blank" rel="noopener"&#10; &gt;colophon spec 0025&lt;/a&gt;). &lt;em&gt;What it costs:&lt;/em&gt; a mistake stays published, so the fix is always another release.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Own the error package.&lt;/strong&gt; Replacing a one-maintainer dependency meant migrating every module, and the saving only lands when the last one moves. The spec says so: a partly migrated estate carries both.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Families, not a monolith.&lt;/strong&gt; The extraction playbook&amp;rsquo;s own verdict is that moving the tests is the bulk of the work, and a family of modules means a release round across every member whenever its core changes, which a monolith never needed.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Generality, proven first.&lt;/strong&gt; Messaging specified three backends unlike NATS before building NATS, and they forced nine changes to the contract (&lt;a class="link" href="https://phpboyscout.uk/showcase/messaging/" &gt;messaging&lt;/a&gt;). That was slower, and it&amp;rsquo;s why the abstraction holds.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The stakes set the default.&lt;/strong&gt; Fail closed where a wrong answer leaks something or runs untrusted code, and fail open only where refusing would strand a user, as self-update does. &lt;em&gt;What it cost:&lt;/em&gt; the estate is deliberately inconsistent here, and each exception has to be written down.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;98 Go repositories, 54 of them in eight families, and every one is pre-1.0. A breaking change ships as a minor version, with the migration written into the release notes before it merges (&lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt;).&lt;/li&gt;&#10;&lt;li&gt;go/errors is required by 92 of them, and 34 of the toolkit modules take the standard logger.&lt;/li&gt;&#10;&lt;li&gt;The tool patterns hold across keryx, krites, colophon, sigillum, skillup and go-tool-base, and Rust&amp;rsquo;s framework reaches the same outcomes with its own idioms.&lt;/li&gt;&#10;&lt;li&gt;The patterns are written down for agents as much as for people. The estate&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/claude-code-plugins/-/tree/main/plugins/phpboyscout-go" target="_blank" rel="noopener"&#10; &gt;Go module skills&lt;/a&gt; teach them to every session that creates or uses a module (&lt;a class="link" href="https://phpboyscout.uk/showcase/agent-skills/" &gt;Agent skills&lt;/a&gt;).&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="borrow-it-when-and-when-not-to"&gt;Borrow it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Borrow these if you&amp;rsquo;re building a library other people will import, and want them to take only what they need. The dependency-budget test and the lying-backend suite carry over to any language with a dependency lister and a test runner. The tool patterns carry over to any command-line tool that acts on someone&amp;rsquo;s behalf, and most of all to one an agent will drive.&lt;/p&gt;&#10;&lt;p&gt;They cost more up front than a single package does: more modules, more release coordination, and a mapping layer in every application. For a single application that will never be imported, most of this is overhead.&lt;/p&gt;&#10;</description></item><item><title>How it's tested</title><link>https://phpboyscout.uk/showcase/how-its-tested/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/how-its-tested/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;When agents write a lot of the code, a green test suite is easy to come by and hard to believe. A test written to pass passes, a suite that skips everything is green, and a check that has never failed may not check anything. So the estate&amp;rsquo;s testing is built around one question: has anyone seen this fail? In the words of the estate&amp;rsquo;s own test-first skill, &amp;ldquo;A test you never saw fail has not been shown to test anything.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/how-i-work/" &gt;How I work&lt;/a&gt; covers the process and &lt;a class="link" href="https://phpboyscout.uk/showcase/estate-architectural-patterns/" &gt;Estate architectural patterns&lt;/a&gt; the design. This page is the evidence behind both.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="red-before-green"&gt;Red before green&#10;&lt;/h3&gt;&lt;p&gt;A change starts with a failing test, and a bug fix starts by proving the bug against the untouched code. The estate&amp;rsquo;s skills hold every agent session to that: write the test, &amp;ldquo;watch it fail&amp;rdquo;, then write only enough code to pass it, and when something breaks, &amp;ldquo;You do not get to theorise until you have a command that goes red on this bug&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/claude-code-plugins/-/blob/main/plugins/phpboyscout-common/skills/test-first-discipline/SKILL.md" target="_blank" rel="noopener"&#10; &gt;test-first-discipline&lt;/a&gt;). &lt;a class="link" href="https://phpboyscout.uk/prove-the-bug-before-you-fix-it/" &gt;Prove the bug before you fix it&lt;/a&gt; is the story of why.&lt;/p&gt;&#10;&lt;h3 id="every-go-repository-under-the-race-detector"&gt;Every Go repository under the race detector&#10;&lt;/h3&gt;&lt;p&gt;97 Go repositories run their tests through one cicd component, with Go&amp;rsquo;s race detector on, a coverage report and a summary of what was skipped (&lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-test/" target="_blank" rel="noopener"&#10; &gt;go-test&lt;/a&gt;). Its time limit is deliberately twice Go&amp;rsquo;s default, because a race-enabled suite near the limit &amp;ldquo;fails with a goroutine dump that reads exactly like a deadlock and is not one&amp;rdquo;. go-tool-base bans the package-level mocking hooks that would race under parallel tests.&lt;/p&gt;&#10;&lt;h3 id="suites-that-prove-themselves"&gt;Suites that prove themselves&#10;&lt;/h3&gt;&lt;p&gt;Every family&amp;rsquo;s core ships one test suite that each member runs unchanged: 14 repositories run config&amp;rsquo;s backend suite, and the chat providers, forge adapters and messaging backends each run theirs. A suite like that is only worth something if it can fail, so messaging, forge and comms test the suite itself against deliberately broken backends. Messaging&amp;rsquo;s reason: &amp;ldquo;A capability that lies is worse than one that is absent&amp;rdquo;. Each broken backend runs in a child process, and the test &amp;ldquo;passes here only if it failed there&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/messaging/-/blob/7dc48e6/conformance/lies_test.go#L10-14" target="_blank" rel="noopener"&#10; &gt;messaging&lt;/a&gt;). chat and config check theirs against a simple in-memory provider that&amp;rsquo;s correct by construction, and none of the suites brings in an assertion library, so a provider that runs one takes on no new dependency.&lt;/p&gt;&#10;&lt;h3 id="a-skip-is-not-a-pass"&gt;A skip is not a pass&#10;&lt;/h3&gt;&lt;p&gt;Tests that need a real service, a network or a built binary are always compiled and skipped unless an environment variable asks for them, never hidden behind a build tag, where they &amp;ldquo;rot silently&amp;rdquo; (&lt;a class="link" href="https://gtb.phpboyscout.uk/development/integration-testing/" target="_blank" rel="noopener"&#10; &gt;env-gated tests&lt;/a&gt;). 37 repositories gate suites this way, and 14 of them start the real service in a container. A skip still has to tell the truth. When the variable is set and the dependency is missing, the test fails, because &amp;ldquo;Somebody asked for them, and they cannot run&amp;rdquo; (&lt;a class="link" href="https://cicd.phpboyscout.uk/explanation/a-skip-is-not-a-pass/" target="_blank" rel="noopener"&#10; &gt;a skip is not a pass&lt;/a&gt;). That rule came from a green pipeline whose skipped tests, once running, took one package&amp;rsquo;s coverage from 52% to 91%.&lt;/p&gt;&#10;&lt;h3 id="fuzzing-for-properties"&gt;Fuzzing for properties&#10;&lt;/h3&gt;&lt;p&gt;73 fuzz targets across 12 repositories, most of them named for the property they protect rather than for &amp;ldquo;doesn&amp;rsquo;t crash&amp;rdquo;: a decryption that never emits unauthenticated text, a routing table that can never bypass discovery, a render that always keeps the body. The seeds replay in every ordinary test run. Four repositories also run a short fuzz search on every merge request, because four review rounds, &amp;ldquo;roughly 11M tokens of agent time&amp;rdquo;, never found the defect that one did: &amp;ldquo;A fuzz target found it in three seconds&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/encryption/-/blob/cf887f3/.gitlab-ci.yml#L58-97" target="_blank" rel="noopener"&#10; &gt;go/encryption&lt;/a&gt;). In yamldoc, which has 40 of the targets, &amp;ldquo;Fuzzing is a merge gate, not a nightly job&amp;rdquo;.&lt;/p&gt;&#10;&lt;h3 id="properties-over-snapshots"&gt;Properties over snapshots&#10;&lt;/h3&gt;&lt;p&gt;A snapshot test compares output byte for byte with a saved copy, and the estate mostly avoids them. ffmpeg-wasi checks properties of its output instead, such as stream counts and durations, because a snapshot suite &amp;ldquo;goes red on every FFmpeg bump, which would make the instrument useless at the exact moment it is needed&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi/-/blob/b82ea64/internal/conformance/behaviour_test.go#L1-18" target="_blank" rel="noopener"&#10; &gt;ffmpeg-wasi&lt;/a&gt;). Where snapshots are kept, they&amp;rsquo;re pinned to a reference on purpose: keryx&amp;rsquo;s card renderer is compared with images from the renderer it replaced, and regenerating them from the new one &amp;ldquo;would make the test agree with whatever the code now does, which is the same as deleting it&amp;rdquo;. sigillum turned a defect that came back six times into one structural test, because &amp;ldquo;Written as six separate findings they look like six unrelated bugs. Written as a property they are one.&amp;rdquo;&lt;/p&gt;&#10;&lt;h3 id="mutation-as-a-ratchet"&gt;Mutation as a ratchet&#10;&lt;/h3&gt;&lt;p&gt;Mutation testing changes the code on purpose and checks that a test notices. sigillum runs it on five packages on every merge request and fails if a change goes unnoticed. It calls it &amp;ldquo;a ratchet, not a detector&amp;rdquo;: it was added while hunting a class of defect and didn&amp;rsquo;t find it, because &amp;ldquo;The line was covered; the state was not&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/sigillum/-/blob/79e25a6/.gitlab-ci.yml#L142-184" target="_blank" rel="noopener"&#10; &gt;sigillum&lt;/a&gt;). comms-discord does it by hand. A row of its contract suite only counts as covered once its check has been watched failing under the change the row names.&lt;/p&gt;&#10;&lt;h3 id="scenarios-for-whole-workflows"&gt;Scenarios for whole workflows&#10;&lt;/h3&gt;&lt;p&gt;Behaviour that spans a whole command or a lifecycle is written as Gherkin scenarios and run against a built binary with godog. go-tool-base uses them &amp;ldquo;strategically for CLI workflows and state machine scenarios&amp;rdquo;, with table-driven unit tests as the baseline (&lt;a class="link" href="https://gtb.phpboyscout.uk/development/feature-decisions/" target="_blank" rel="noopener"&#10; &gt;architectural decisions&lt;/a&gt;), and in go-tool-base a new command needs scenarios before it merges. Ten repositories have them: go-tool-base has 290, keryx 121, colophon 97, Scout.DM 65 and krites 55. The Rust crates use the cucumber equivalent.&lt;/p&gt;&#10;&lt;h3 id="what-a-unit-test-cant-see"&gt;What a unit test can&amp;rsquo;t see&#10;&lt;/h3&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;A dependency budget.&lt;/strong&gt; 86 repositories carry a test that lists everything the module pulls in and fails the build when something forbidden turns up (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/redact/-/blob/9a4bac2/depfootprint_test.go#L22-50" target="_blank" rel="noopener"&#10; &gt;go/redact&lt;/a&gt;).&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Reproducible images.&lt;/strong&gt; Seven build images are built twice from scratch on every change, and the job fails if they differ, &amp;ldquo;rather than asserting that it is&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/images/go-tools/-/blob/ed3bc23/.gitlab-ci.yml#L124-184" target="_blank" rel="noopener"&#10; &gt;go-tools&lt;/a&gt;).&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Docs against the code.&lt;/strong&gt; krites&amp;rsquo; docs are checked on every merge request for commands that don&amp;rsquo;t exist, by a test that has itself been watched failing (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/4b7fe54/pkg/cmd/root/docs_commands_test.go#L63-90" target="_blank" rel="noopener"&#10; &gt;krites&lt;/a&gt;).&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The right architecture.&lt;/strong&gt; go/encryption and sigillum run their suites as 32-bit builds too, because one overflow test asserts nothing on a 64-bit machine, and &amp;ldquo;A test that only means something on one architecture has to be RUN on that architecture, or it is decoration.&amp;rdquo;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="front-ends-and-rust"&gt;Front ends and Rust&#10;&lt;/h3&gt;&lt;p&gt;The &lt;a class="link" href="https://phpboyscout.uk/showcase/web-front-ends/" &gt;web front ends&lt;/a&gt; are tested with Vitest, testing-library and svelte-check, and the studios with Playwright. Before that runs, a hard check confirms the browsers are actually installed, because the earlier job &amp;ldquo;failed in 12 seconds and &lt;code&gt;allow_failure: true&lt;/code&gt; reported that as a green pipeline for three runs&amp;rdquo; (&lt;a class="link" href="https://cicd.phpboyscout.uk/reference/svelte/svelte-test/" target="_blank" rel="noopener"&#10; &gt;svelte-test&lt;/a&gt;). All 12 Rust repositories run cargo-nextest, because some tests &amp;ldquo;only pass with nextest&amp;rsquo;s process-per-test isolation&amp;rdquo;, alongside clippy with warnings as errors, cargo-deny and a coverage floor that fails the build (&lt;a class="link" href="https://cicd.phpboyscout.uk/reference/rust/rust-test/" target="_blank" rel="noopener"&#10; &gt;rust-test&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Runtime gates, not build tags.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; container suites need a privileged runner, and a run with the variable off reports a lot of skips.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The race detector on every run.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; a slower suite, and a real hang takes the full 20 minutes to be reported.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Fuzz on merge requests.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; the budget is seconds per target, so a deep search still happens on a developer&amp;rsquo;s machine, and in sigillum &amp;ldquo;a quiet week fuzzes less than a busy one&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Mutation where coverage lied.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; twelve seconds a run in sigillum, and the reminder it carries: &amp;ldquo;Do not mistake a green run for evidence the suite is adequate.&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Two coverage rules.&lt;/strong&gt; Rust&amp;rsquo;s floor fails the build. go-tool-base checks every package against 90% but only reports, so it &amp;ldquo;can never flake the pipeline red&amp;rdquo;. &lt;em&gt;What it cost:&lt;/em&gt; a Go package can slip below the line until someone reads the report.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;97 Go repositories under the race detector, and 12 Rust repositories under nextest.&lt;/li&gt;&#10;&lt;li&gt;73 fuzz targets in 12 repositories, four with a search on every merge request.&lt;/li&gt;&#10;&lt;li&gt;732 Gherkin scenarios across ten repositories.&lt;/li&gt;&#10;&lt;li&gt;86 repositories with a dependency budget, and seven images checked for reproducibility on every change.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="borrow-it-when-and-when-not-to"&gt;Borrow it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Borrow it if agents write a lot of your code, or if your tests have ever been green while something was broken. The lying-backend suite, the skip that fails when it was asked to run, and the short fuzz search on merge requests carry over to any language with a test runner.&lt;/p&gt;&#10;&lt;p&gt;It costs pipeline time and a privileged runner for the container suites, and the habit of watching every new test fail first is slower than writing it to pass. For a script you&amp;rsquo;ll throw away, most of it is overhead.&lt;/p&gt;&#10;</description></item><item><title>Agent skills</title><link>https://phpboyscout.uk/showcase/agent-skills/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/agent-skills/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;An agent reads its instruction files and does what they say, so those files behave like code. Copied from one repository to the next, they drift. Accept them from strangers, and anyone can hide an instruction inside one.&lt;/p&gt;&#10;&lt;p&gt;So the habits behind &lt;a class="link" href="https://phpboyscout.uk/showcase/how-i-work/" &gt;How I work&lt;/a&gt; live in one place, &lt;a class="link" href="https://gitlab.com/phpboyscout/claude-code-plugins" target="_blank" rel="noopener"&#10; &gt;a public plugin marketplace&lt;/a&gt;: 66 skills across 8 plugins, plus 5 subagents and 21 commands, installable into Claude Code or Codex. Versioning is where it bit first: before it was automated, one plugin shipped nothing to anyone for six weeks, because its version number never moved.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="one-folder-per-plugin-one-catalogue"&gt;One folder per plugin, one catalogue&#10;&lt;/h3&gt;&lt;p&gt;Each plugin is a folder with an owner and a version of its own, and a single catalogue lists them all. You add the marketplace once and install the plugins you want.&lt;/p&gt;&#10;&lt;h3 id="a-security-gate-on-every-change"&gt;A security gate on every change&#10;&lt;/h3&gt;&lt;p&gt;Every merge request runs a shared security check over the skills. It fails a change on hidden characters (zero-width or Trojan Source tricks), on an invalid manifest or skill header, and on leaked secrets, and it warns on instruction patterns that look like an injection. Two more checks make sure a skill is declared the same way for Claude and for Codex, and that the prose doesn&amp;rsquo;t carry the usual AI tells. The scanners are cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/skills/skill-security/" target="_blank" rel="noopener"&#10; &gt;skill-security component&lt;/a&gt;, so any repository of skills or agent instruction files can include the same gate, and &lt;a class="link" href="https://phpboyscout.uk/showcase/the-pipeline/" &gt;the pipeline&lt;/a&gt; keeps them current. &lt;a class="link" href="https://phpboyscout.uk/the-marketplace-i-had-to-defend-from-my-own-attack-surface/" &gt;The marketplace I had to defend from my own attack surface&lt;/a&gt; is why.&lt;/p&gt;&#10;&lt;h3 id="skillup-versions-that-cant-stall"&gt;skillup: versions that can&amp;rsquo;t stall&#10;&lt;/h3&gt;&lt;p&gt;A plugin&amp;rsquo;s version number is what delivers its update. Claude Code caches each plugin under its version and never looks at a git branch or tag, so in the words of its own docs, users only receive updates when that field changes. A version that doesn&amp;rsquo;t move doesn&amp;rsquo;t delay a change. It withholds it, with no error and nothing in CI to notice.&lt;/p&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://skillup.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;skillup&lt;/a&gt; is the tool that keeps every version moving. It treats each plugin in the marketplace as a segment with its own version, and it has four jobs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;plan&lt;/code&gt; shows what each segment&amp;rsquo;s next version should be, and changes nothing.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;apply&lt;/code&gt; writes those versions into the manifests and does nothing else: no commit, no tag, no push. The contributor commits the bump alongside the change that earned it, so a plugin&amp;rsquo;s content and its version never disagree on the main branch.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;check&lt;/code&gt; is the gate. It fails a merge request when any segment has fallen behind, and it also reports a plugin the catalogue doesn&amp;rsquo;t list (so nobody can install it) or a catalogue version that contradicts the plugin&amp;rsquo;s own.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;tag&lt;/code&gt; adds a tag per segment at the commit that set each version, after the fact if need be.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The trick is where it starts counting from. Release tools usually take the last git tag as the baseline, which makes tags something a person has to keep cutting on time, forever. skillup takes each segment&amp;rsquo;s baseline from the history of its own manifest, the commit where its version last changed. So it&amp;rsquo;s correct on a repository that has never been tagged, a late tag costs nothing but visibility, and tags can be added afterwards because the history still says where every version was set.&lt;/p&gt;&#10;&lt;p&gt;Its rules are the cautious ones. A change counts towards a segment by the files it touches, not by the label on the commit. It never lowers a version, because commit messages under-describe some changes (removing a skill breaks its users, but tends to land as a refactor), and reaching 1.0 is the author&amp;rsquo;s promise to make, not a sum. Below 1.0 a breaking change is a minor bump. It edits the version and nothing else in the file, rather than rewriting the JSON and turning a one-character change into an eighteen-line diff. And running it twice before committing doesn&amp;rsquo;t bump twice.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s a go-tool-base tool, and all its git work goes through go/repo, so there&amp;rsquo;s no git binary to shell out to (&lt;a class="link" href="https://phpboyscout.uk/showcase/git-and-forges/" &gt;git and forges&lt;/a&gt;). It runs as a gate on GitLab or GitHub, with a &lt;a class="link" href="https://skillup.phpboyscout.uk/how-to/" target="_blank" rel="noopener"&#10; &gt;setup guide&lt;/a&gt; for each, and &lt;a class="link" href="https://phpboyscout.uk/your-first-run-with-skillup/" &gt;Your first run with skillup&lt;/a&gt; walks through one.&lt;/p&gt;&#10;&lt;h3 id="docscheck"&gt;docscheck&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://docscheck.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;docscheck&lt;/a&gt; is a smaller sibling, checking that the commands a tool&amp;rsquo;s docs show you are commands the tool can actually run. krites runs it on every merge request through cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/quality/docs-verify/" target="_blank" rel="noopener"&#10; &gt;docs-verify component&lt;/a&gt;, which runs a project&amp;rsquo;s own docs check with no change detection at all, because a docs gate that only wakes up for docs changes misses the code changes that break the docs.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Version from history, not tags.&lt;/strong&gt; skillup reads each plugin&amp;rsquo;s manifest history instead of the last git tag. The existing tools were tried first: one had no monorepo support, one was GitHub-only, one bumped every file to a single version, and the closest fit took its baseline from tags. The tag-based tools proposed 0.1.0 for every plugin on an untagged repository, and gave two different states of the main branch the same version (&lt;a class="link" href="https://gitlab.com/phpboyscout/skillup/-/wikis/spikes/versioning-utility" target="_blank" rel="noopener"&#10; &gt;the spike&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; about 600 lines of Go to maintain, repeating parsing other tools already do.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;CI checks, people bump.&lt;/strong&gt; The pipeline only checks the version; the contributor runs the bump and commits it. A pipeline that pushed its own bump would start no new pipeline, leaving the merge request waiting forever. &lt;em&gt;What it cost:&lt;/em&gt; a manual step on every change.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Deep members decide.&lt;/strong&gt; The research panel puts one question to two &amp;ldquo;deep&amp;rdquo; agents, who give the verdict, and one &amp;ldquo;broad&amp;rdquo; agent, who only steers, each working on a throwaway copy of the files inside a sandbox (&lt;a class="link" href="https://gitlab.com/phpboyscout/claude-code-plugins/-/wikis/specs/0002-a-research-and-review-panel-across-harnesses" target="_blank" rel="noopener"&#10; &gt;spec 0002&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; one of the three tools has no read-only mode, and on macOS the sandbox can&amp;rsquo;t keep a member&amp;rsquo;s own state apart.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A clock the agent can&amp;rsquo;t ignore.&lt;/strong&gt; The timebox skill comes with hooks that read the real clock and stop an agent quitting before its time (&lt;a class="link" href="https://gitlab.com/phpboyscout/claude-code-plugins/-/wikis/specs/0001-a-timebox-with-a-wall-clock" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;), because a skill alone relies on remembering to look. &lt;em&gt;What it cost:&lt;/em&gt; Codex gets the skill without the hooks.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;It&amp;rsquo;s what this estate&amp;rsquo;s own agent sessions run on. The marketplace has had 292 commits since June 2026, and its plugins version on their own: the common plugin, the biggest, is at 0.46.&lt;/li&gt;&#10;&lt;li&gt;skillup gates every merge request to the marketplace, pinned at v0.3.1.&lt;/li&gt;&#10;&lt;li&gt;docscheck checks krites&amp;rsquo; docs: 57 files and 97 documented commands, every real error caught and no false alarms.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Borrow it if you work with AI coding agents and want your habits written down once, installed everywhere, and safe to accept changes to. The general skills don&amp;rsquo;t assume my projects.&lt;/p&gt;&#10;&lt;p&gt;skillup only understands Claude Code plugin marketplaces, writes no changelogs, and needs the full git history (a shallow clone gives an answer that&amp;rsquo;s quietly wrong). docscheck checks command paths only, not flags or prose. The review panel needs a second agent tool with quota to spare, and falls back to one agent, visibly, without it. Hooks don&amp;rsquo;t carry over to Codex, and output styles are Claude-only.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Rust and Terraform skills, a checker for skills that have drifted from their own repositories, and the panel and timebox findings deferred from their first release.&lt;/p&gt;&#10;</description></item><item><title>AWS account foundation</title><link>https://phpboyscout.uk/showcase/aws-account-foundation/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/aws-account-foundation/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Every new AWS account needs the same four things before anything useful can happen in it: somewhere to keep infrastructure state, a way for CI to sign in without stored keys, guardrails, and alerts for when something goes wrong. Working those out again by hand for every account is, in the words of the module&amp;rsquo;s own spec, waste. So they&amp;rsquo;re two modules, applied in order, and the second is built without leaning on anybody&amp;rsquo;s framework: 52 AWS resources across six parts, and no third-party modules among them.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="first-the-bootstrap"&gt;First, the bootstrap&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://aws-bootstrap.iac.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;The bootstrap module&lt;/a&gt; does three things and deliberately stops there. It makes an encrypted state bucket that refuses to be destroyed (with locking inside S3 itself, so no DynamoDB table), a role CI can assume through OIDC from GitHub or GitLab with no stored credentials anywhere, and a generated configuration for aws-nuke, so a throwaway account can be wiped clean on purpose.&lt;/p&gt;&#10;&lt;h3 id="then-the-baseline"&gt;Then the baseline&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://aws-security-baseline.iac.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;The security baseline&lt;/a&gt; is applied through that CI role, and comes in six parts, each of which can be switched off:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;account hardening&lt;/li&gt;&#10;&lt;li&gt;an audit trail in CloudTrail, kept for two years&lt;/li&gt;&#10;&lt;li&gt;a configuration history in AWS Config&lt;/li&gt;&#10;&lt;li&gt;threat detection, with GuardDuty, Security Hub running the AWS best-practice and CIS standards, and Access Analyzer&lt;/li&gt;&#10;&lt;li&gt;alerts for high or critical findings, and for any use of the root login&lt;/li&gt;&#10;&lt;li&gt;an operator role that needs MFA and is locked to one region&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Keep the bootstrap narrow.&lt;/strong&gt; State, CI sign-in and the nuke configuration only, instead of one module that sets up the whole account. &lt;em&gt;What it cost:&lt;/em&gt; two modules and two version streams to keep in step.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;No frameworks.&lt;/strong&gt; Cloud Posse, Gruntwork and Control Tower were all considered and turned down, and even the community modules the first draft planned to use for three of the baseline&amp;rsquo;s parts ended up written by hand. &lt;em&gt;What it cost:&lt;/em&gt; every line is mine to maintain, and getting the audit trail&amp;rsquo;s bucket protected the way I wanted took about fifty more of them.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Half rewritten.&lt;/strong&gt; GitLab&amp;rsquo;s OIDC sign-in was written by hand, but GitHub&amp;rsquo;s still wraps the community module, because rewriting both would have pushed existing users through a state migration. &lt;em&gt;What it cost:&lt;/em&gt; two code paths for one job until 1.0 tidies it up.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;No lock table.&lt;/strong&gt; State locking uses S3&amp;rsquo;s own locking instead of DynamoDB. &lt;em&gt;What it cost:&lt;/em&gt; it needs OpenTofu, or Terraform 1.10 or later.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;The bootstrap is at v0.3 and the baseline at v0.2, both from July 2026, and both docs sites are live. Each release tag publishes the module to GitLab&amp;rsquo;s module registry through cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/tofu/tofu-module-publish/" target="_blank" rel="noopener"&#10; &gt;tofu-module-publish component&lt;/a&gt;, and cicd&amp;rsquo;s guide to signing its &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/tofu/tofu-plan/" target="_blank" rel="noopener"&#10; &gt;plan&lt;/a&gt; and &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/tofu/tofu-apply/" target="_blank" rel="noopener"&#10; &gt;apply&lt;/a&gt; jobs in to AWS can reuse the bootstrap&amp;rsquo;s CI role.&lt;/li&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/why-i-hand-rolled-every-module/" &gt;Why I hand-rolled every module&lt;/a&gt; is the argument behind building them this way.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you&amp;rsquo;re standing up a single AWS account and want the boring, important parts done properly before you build anything in it.&lt;/p&gt;&#10;&lt;p&gt;Know what it isn&amp;rsquo;t. It&amp;rsquo;s one account at a time: AWS Organizations, Identity Center, WAF, Inspector, Macie and Detective are all out of scope. GuardDuty runs in the main region only. The CI role gets administrator access unless you narrow it, which you should. Security Hub and GuardDuty cost money, and a brand-new account can refuse them until it&amp;rsquo;s subscribed, which is why each has its own switch. And it&amp;rsquo;s below 1.0, so pin a tag.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;The region lock the baseline gives its operator role is meant to reach the bootstrap&amp;rsquo;s CI role too, and separate plan and apply roles with a permission boundary are on the roadmap. Neither has been built yet.&lt;/p&gt;&#10;</description></item><item><title>Build images</title><link>https://phpboyscout.uk/showcase/build-images/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/build-images/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Every CI job runs inside a container image, and the easy thing is one big image with every tool in it. The estate had two. The development image was 977 MB to pull and 2.7 GB unpacked, carrying 2,582 scanner findings. So a job that only needed curl and jq to post a Discord message pulled 2.36 GB to do it, and a Rust repository inherited 66 serious findings, none of which came from Rust (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0074-hardened-base-images" target="_blank" rel="noopener"&#10; &gt;spec 0074&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="one-image-per-kind-of-job"&gt;One image per kind of job&#10;&lt;/h3&gt;&lt;p&gt;Everything starts from &lt;strong&gt;ci-base&lt;/strong&gt;. It&amp;rsquo;s built on Wolfi, a minimal Linux distribution made for containers, pinned to an exact image digest, and carries little more than bash, git, curl, jq, Python and &lt;a class="link" href="https://phpboyscout.uk/showcase/signing-and-trust/" &gt;sigillum&lt;/a&gt;, so any job can sign what it builds. On top of that sit one image per track:&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;Image&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;For&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Carries&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;go-tools&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Go lint, tests, security and releases&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Go, golangci-lint, goreleaser, govulncheck, syft, and the ONNX Runtime library&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;rust-tools&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Rust lint, tests, docs and security&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;rustup with clippy and rustfmt, cargo-nextest, llvm-cov, deny, audit, semver-checks, release-plz&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;node-tools&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Svelte builds and tests&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Node and npm&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;playwright-tools&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;browser tests&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;node-tools plus a headless Chromium&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;tofu-tools&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;OpenTofu modules&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;tofu, tflint with the AWS rules baked in, terraform-docs&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;docs-tools&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the docs sites&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;zensical&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;release-tools&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;releases&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;A job pulls the image for its track and nothing else. Most run as an ordinary user rather than root. The Go and Rust images run as root because the runner checks code out as root.&lt;/p&gt;&#10;&lt;h3 id="built-the-same-way-twice"&gt;Built the same way twice&#10;&lt;/h3&gt;&lt;p&gt;The images are built with Buildah, with timestamps fixed to the commit&amp;rsquo;s own time and the build history left out. Then a check builds the image a second time from scratch and fails if the two don&amp;rsquo;t come out identical, printing exactly what differed. Getting there meant tracking down seven kinds of litter tools leave behind (caches, telemetry files, compiled Python timestamps, stray files in /tmp) rather than blaming the builder (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0098-spike-replace-kaniko" target="_blank" rel="noopener"&#10; &gt;spec 0098&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="scanned-by-who-can-fix-it"&gt;Scanned by who can fix it&#10;&lt;/h3&gt;&lt;p&gt;Each image is scanned twice, split by who can act on the result (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0099-the-nightly-scan-tells-someone-when-its-answer-changes" target="_blank" rel="noopener"&#10; &gt;spec 0099&lt;/a&gt;). Findings in the operating system packages fail the build, because a rebuild clears them. Findings inside upstream tools are reported but don&amp;rsquo;t fail it, because only that tool&amp;rsquo;s maintainer can fix them. docs-tools fails on both, because its Python packages re-resolve on every build, so a fix lands without anyone acting. Every image is rebuilt and rescanned every night without publishing, so a vulnerability disclosed after a release doesn&amp;rsquo;t sit unseen until the next one.&lt;/p&gt;&#10;&lt;h3 id="kept-current-by-bots-that-must-release"&gt;Kept current by bots that must release&#10;&lt;/h3&gt;&lt;p&gt;Every tool version is pinned in the image&amp;rsquo;s build file with a note telling Renovate where to look for new versions, so adding a pin is all the wiring it needs (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0065-renovate-untracked-pin-families" target="_blank" rel="noopener"&#10; &gt;spec 0065&lt;/a&gt;). Patch and minor bumps merge themselves on a green pipeline after a three-day wait. Major bumps wait for a person. A bump to anything an image ships is committed as a fix, so colophon actually cuts a release for it, because an image that changed without a new version once shipped an old colophon for a day (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0081-a-tool-bump-that-changes-an-image-must-release-it" target="_blank" rel="noopener"&#10; &gt;spec 0081&lt;/a&gt;). The new image then reaches the &lt;a class="link" href="https://phpboyscout.uk/showcase/the-pipeline/" &gt;component library&lt;/a&gt;&amp;rsquo;s defaults the same way.&lt;/p&gt;&#10;&lt;h3 id="every-download-checked"&gt;Every download checked&#10;&lt;/h3&gt;&lt;p&gt;Each tool is fetched with the checksum its upstream publishes and refused if it doesn&amp;rsquo;t match. Go is checked against go.dev&amp;rsquo;s own release index. Rust tools come in prebuilt only, through cargo-binstall, because only one of the six publishes a checksum to check against. ONNX Runtime comes from the estate&amp;rsquo;s own &lt;a class="link" href="https://phpboyscout.uk/showcase/secure-artefact-delivery/" &gt;signed artefact channel&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="one-deliberate-exception"&gt;One deliberate exception&#10;&lt;/h3&gt;&lt;p&gt;ffmpeg-wasi-build is the pinned toolchain behind &lt;a class="link" href="https://phpboyscout.uk/showcase/media-in-pure-go/" &gt;ffmpeg-wasi&lt;/a&gt;&amp;rsquo;s builds: compilers, the WebAssembly SDK and build tools baked in once, rather than installed by ten build jobs from a Linux mirror. It exists because one day in September that mirror slowed to 50 kB/s, handed back a package index with the wrong checksum and failed the pipeline. It stays on Debian, not Wolfi, because the native driver it builds promises users a minimum C library version (it runs on Ubuntu 22.04, Debian 12 and RHEL 9), and that&amp;rsquo;s a contract.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Wolfi.&lt;/strong&gt; Docker&amp;rsquo;s hardened images are built to have no shell or package manager, which a CI job needs, and Alpine&amp;rsquo;s C library can&amp;rsquo;t run several prebuilt tools (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0074-hardened-base-images" target="_blank" rel="noopener"&#10; &gt;spec 0074&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; the same package set is 81 MB bigger on Wolfi than on Debian, and the spec says not to sell it on size. The Debian layer alone carried 2,468 findings against Wolfi&amp;rsquo;s none. Its packages can&amp;rsquo;t be pinned to versions, because nothing tracks Wolfi&amp;rsquo;s, so a nightly freshness check stands in.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One per track.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; a fix to ci-base takes three release hops to reach a job, and nine repositories&amp;rsquo; build setups to keep in line.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;No version manager.&lt;/strong&gt; One was measured on tofu-tools at 23 seconds against 9, with two update bots instead of one and no checksum checks. &lt;em&gt;What it cost:&lt;/em&gt; every tool&amp;rsquo;s checksums arrive in a different shape, handled one by one.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Playwright in an image of its own&lt;/strong&gt; (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0076-playwright-tools-image" target="_blank" rel="noopener"&#10; &gt;spec 0076&lt;/a&gt;). Baking it into node-tools would take that image from about 117 MB to about 780 MB for a job two projects use.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Reproducible over fast.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; builds 14% to 67% slower, and a merge request costs about three builds. Builds match from one run to the next, not across weeks, because Wolfi&amp;rsquo;s package index keeps moving.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Retag, don&amp;rsquo;t rebuild, on release&lt;/strong&gt; (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0094-spike-retag-instead-of-rebuild" target="_blank" rel="noopener"&#10; &gt;spec 0094&lt;/a&gt;). Pushing, not building, was most of a release pipeline: 354 of 597 seconds on go-tools. A release now retags the image main already built and tested. Only release-tools does so far.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;119 of 143 estate repositories use at least one of these images, mostly through the component library: release-tools in 105, go-tools in 102, docs-tools in 72 and rust-tools in 12.&lt;/li&gt;&#10;&lt;li&gt;55 releases across the nine images in about eight weeks.&lt;/li&gt;&#10;&lt;li&gt;go-tools against the old development image: 484 MB against 958 MB to pull, and 15 serious findings against 73. rust-tools scans clean on its own.&lt;/li&gt;&#10;&lt;li&gt;Compressed sizes today run from 72 MB for docs-tools and 78 MB for ci-base up to 525 MB for go-tools and 830 MB for rust-tools.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;They&amp;rsquo;re public, so anyone can pull them, but they&amp;rsquo;re built for the estate&amp;rsquo;s own jobs and components rather than as general-purpose images, and they&amp;rsquo;re x86-64 only.&lt;/p&gt;&#10;&lt;p&gt;The images themselves aren&amp;rsquo;t signed and carry no software bill of materials or build provenance, and colophon&amp;rsquo;s own signature isn&amp;rsquo;t checked in release-tools, only its checksum. A checksum catches a corrupted download, not a substituted one. ffmpeg-wasi-build lags the rest: it&amp;rsquo;s still built with kaniko, without the reproducibility check or the nightly scan. The component library&amp;rsquo;s defaults also run a release or two behind the newest images, and 13 repositories still pin the retired development image directly.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Retagging on release for the other eight images, with ci-base last (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/work_items/70" target="_blank" rel="noopener"&#10; &gt;cicd#70&lt;/a&gt;), moving the Rust release component onto rust-tools (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0104-release-plz-on-rust-tools" target="_blank" rel="noopener"&#10; &gt;spec 0104&lt;/a&gt;, &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/work_items/71" target="_blank" rel="noopener"&#10; &gt;cicd#71&lt;/a&gt;), signed images with provenance (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/work_items/72" target="_blank" rel="noopener"&#10; &gt;cicd#72&lt;/a&gt;), bringing ffmpeg-wasi-build in line with the rest (&lt;a class="link" href="https://gitlab.com/phpboyscout/images/ffmpeg-wasi-build/-/work_items/1" target="_blank" rel="noopener"&#10; &gt;ffmpeg-wasi-build#1&lt;/a&gt;), and moving the last 13 repositories off the retired image (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/work_items/73" target="_blank" rel="noopener"&#10; &gt;cicd#73&lt;/a&gt;).&lt;/p&gt;&#10;</description></item><item><title>chat</title><link>https://phpboyscout.uk/showcase/chat/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/chat/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Talking to one AI provider from Go is easy. Talking to several is where it goes wrong: every vendor SDK has its own idea of a conversation, a tool call, a stream and an error, and pulling three of them into a project drags in three dependency trees whether you use them or not. The usual answer is a big framework, and for AI integration the de facto standard is LangChain (in my opinion, anyway). It&amp;rsquo;s capable, and it exists for a real need, but it&amp;rsquo;s a lot to swallow when what you want is &amp;ldquo;send this, get an answer back&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;go/chat is the client behind &lt;code&gt;gtb ai&lt;/code&gt;, pulled out so a project can use it without go-tool-base and without linking every vendor&amp;rsquo;s SDK. How heavy an unused dependency gets came up early: the MCP SDK alone was 1.35 MB and eight modules for a consumer that never touched it (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0021-claude-local-extraction" target="_blank" rel="noopener"&#10; &gt;spec 0021&lt;/a&gt;), and making it opt-in (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0028-mcp-bridge-as-a-link" target="_blank" rel="noopener"&#10; &gt;spec 0028&lt;/a&gt;) is the same thinking applied to everything else.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="one-conversation-whichever-provider"&gt;One conversation, whichever provider&#10;&lt;/h3&gt;&lt;p&gt;You get one small client: add a message, chat, ask for a structured answer straight into a Go value, hand it some tools, and read back the history and the usage. Streaming, persistence and caching are there when a provider can do them, and absent rather than faked when it can&amp;rsquo;t. Around that sit the things every real use ends up needing anyway: a tool loop, fallback to another provider when one falls over, history that stays within bounds and compacts itself, and encrypted persistence.&lt;/p&gt;&#10;&lt;h3 id="opt-in-to-the-providers-you-use"&gt;Opt in to the providers you use&#10;&lt;/h3&gt;&lt;p&gt;Every vendor lives in a module of its own, switched on with a single import, and the core depends on almost nothing (a test fails the build if a vendor SDK, a CLI framework or OpenTelemetry ever sneaks into its graph). Ten providers across five modules today, and a sixth on the way:&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;Module&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Providers&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;chat-anthropic&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Claude through the API, and Claude Code running locally&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;chat-openai&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;OpenAI, any OpenAI-compatible server, and Codex running locally&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;chat-gemini&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Gemini, Gemini on Vertex, and Antigravity running locally&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;chat-bedrock&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Amazon Bedrock&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;chat-openai-azure&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Azure OpenAI&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;chat-vllm&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A self-hosted &lt;a class="link" href="https://docs.vllm.ai/" target="_blank" rel="noopener"&#10; &gt;vLLM&lt;/a&gt; server (in progress, not released yet)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The local ones are the interesting bit for me: they drive a coding agent&amp;rsquo;s own CLI, so a tool uses whatever that CLI is already signed in with, instead of needing an API key of its own. &lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat-mcptools" target="_blank" rel="noopener"&#10; &gt;chat-mcptools&lt;/a&gt; bridges your tools into those local agents over a loopback MCP server, so they can call them too.&lt;/p&gt;&#10;&lt;h3 id="what-a-model-can-do-is-data-and-dont-know-is-an-answer"&gt;What a model can do is data, and &amp;ldquo;don&amp;rsquo;t know&amp;rdquo; is an answer&#10;&lt;/h3&gt;&lt;p&gt;Each provider reports what it supports (streaming, tools, structured output, caching and the rest) and every answer is one of three values: yes, no, or &lt;em&gt;unknown&lt;/em&gt;. Unknown is the default, and it means &amp;ldquo;go ahead and find out&amp;rdquo;, because the alternative is worse. As the code puts it, reporting a provider that can&amp;rsquo;t be asked as unsupported &amp;ldquo;is a lie which suppresses working features&amp;rdquo;. Only a confident no changes behaviour.&lt;/p&gt;&#10;&lt;p&gt;The answers come from the caller first, then a live probe, then a catalogue generated from &lt;a class="link" href="https://models.dev/" target="_blank" rel="noopener"&#10; &gt;models.dev&lt;/a&gt; and refreshed on a schedule. The catalogue is built ahead of time and never fetched while your program runs.&lt;/p&gt;&#10;&lt;h3 id="every-provider-sits-the-same-exam"&gt;Every provider sits the same exam&#10;&lt;/h3&gt;&lt;p&gt;A conformance suite ships with the core, and every provider module runs it to prove it behaves like the others: capabilities, structured answers, history, timeouts, streams, errors, concurrency and refusals, nine groups of them. It&amp;rsquo;s how ten providers from five vendors end up feeling like one client.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Not LangChain.&lt;/strong&gt; Before a line of it was written, LangChain Go, go-openai, Vercel&amp;rsquo;s AI SDK and about ten others were weighed up, and none of them fitted: the aim was something much lighter and simpler, an interface small enough to fit on one screen (&lt;a class="link" href="https://gtb.phpboyscout.uk/development/ai-integration/" target="_blank" rel="noopener"&#10; &gt;the design notes&lt;/a&gt;, and &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;the post that argued it&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; owning it, and it has grown a long way since. It started as four methods and five providers inside go-tool-base, and it&amp;rsquo;s now its own module family with ten providers, a tool loop, fallback, history compaction and capability catalogues. It&amp;rsquo;s still a long way short of what LangChain does, which is rather the point, but &amp;ldquo;small&amp;rdquo; has had to stretch.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Three-valued capabilities.&lt;/strong&gt; A fixed vocabulary of capabilities, each yes, no or unknown, instead of a bool (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0006-capability-reporting" target="_blank" rel="noopener"&#10; &gt;spec 0006&lt;/a&gt;), because almost nothing a caller needs to know is really a bool. &lt;em&gt;What it cost:&lt;/em&gt; Claude Code locally and an arbitrary OpenAI-compatible server can only ever say unknown, and unknown can&amp;rsquo;t warn you about anything.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Degrade, don&amp;rsquo;t refuse.&lt;/strong&gt; A misconfigured setting doesn&amp;rsquo;t stop you getting a client: you get one that works, plus a list of the settings it had to drop (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0007-degraded-construction" target="_blank" rel="noopener"&#10; &gt;spec 0007&lt;/a&gt;). Failing hard, dropping them quietly into a log, and a strict-mode switch were all turned down. &lt;em&gt;What it cost:&lt;/em&gt; that list lives in the error you get back, and nowhere on the client afterwards. &lt;a class="link" href="https://phpboyscout.uk/a-client-you-can-still-use-when-you-configured-it-wrong/" &gt;The post on it&lt;/a&gt; has the whole argument.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Conformance ships in the core.&lt;/strong&gt; The suite lives inside go/chat itself instead of a separate module (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0011-chat-provider-conformance" target="_blank" rel="noopener"&#10; &gt;spec 0011&lt;/a&gt;), because a fourth thing to version would have been one more thing to drift. &lt;em&gt;What it cost:&lt;/em&gt; the dependency test had to be narrowed to just the packages that reach a consumer.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Structured output.&lt;/strong&gt; Structured answers use each provider&amp;rsquo;s own structured-output feature instead of a forced tool call wrapped in instructions (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0024-ask-via-structured-outputs" target="_blank" rel="noopener"&#10; &gt;spec 0024&lt;/a&gt;). The spike behind it had 15 of 15 models accept it, and 29 of 29 replies came back valid against the schema. &lt;em&gt;What it cost:&lt;/em&gt; about 35 billable calls to find out, and a change to how those answers sit in the history.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;Five projects in the estate use it: &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; for its AI commands, keryx to write social copy, krites as the critic that reviews a photo cull, phpbotscout to answer support questions, and &lt;a class="link" href="https://scoutdm.com/" target="_blank" rel="noopener"&#10; &gt;Scout&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;39 releases of the core since 12 July 2026, and between them the provider modules have shipped another 120. Each provider&amp;rsquo;s release merge request runs cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-core-currency/" target="_blank" rel="noopener"&#10; &gt;go-core-currency&lt;/a&gt; check, so a provider can&amp;rsquo;t ship against a core that&amp;rsquo;s moved on unnoticed.&lt;/li&gt;&#10;&lt;li&gt;The conformance suite runs in six suites across five provider modules, and the family carries 836 test, fuzz and example functions.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you want an AI provider (or several, with fallback between them) in a Go program without inheriting every vendor&amp;rsquo;s SDK, and you&amp;rsquo;d like structured answers, tools and streaming to look the same whichever one you&amp;rsquo;re talking to. Install a provider module and let it pick the core version for you.&lt;/p&gt;&#10;&lt;p&gt;Don&amp;rsquo;t use it for anything beyond chat. There&amp;rsquo;s no image generation, embeddings or audio, no rate limiting or spend cap, and one client talks to one provider at a time (fallback is for when one falls over, not a router). Claude Code locally can&amp;rsquo;t stream, and a refusal is only spotted where the provider marks it as one. The &lt;a class="link" href="https://chat.go.phpboyscout.uk/explanation/limitations/" target="_blank" rel="noopener"&#10; &gt;limitations page&lt;/a&gt; has the full list. And it&amp;rsquo;s pre-1.0, so the provider modules move with the core&amp;rsquo;s minor version.&lt;/p&gt;&#10;&lt;p&gt;There&amp;rsquo;s a Rust one too, if that&amp;rsquo;s your language: &lt;a class="link" href="https://docs.rs/rtb-chat" target="_blank" rel="noopener"&#10; &gt;rtb-chat&lt;/a&gt;, part of rust-tool-base.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;vLLM is next. &lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0035-vllm-adapter" target="_blank" rel="noopener"&#10; &gt;Spec 0035&lt;/a&gt;, approved on 10 October 2026, adds chat-vllm for a self-hosted vLLM server, as a module of its own with its own HTTP client rather than a layer over the OpenAI provider. It ships two things the other providers will get later, once a live run has confirmed them: &lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0032-token-log-probabilities" target="_blank" rel="noopener"&#10; &gt;token log-probabilities&lt;/a&gt; and &lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/wikis/specs/0034-tool-calls-without-execution" target="_blank" rel="noopener"&#10; &gt;tool calls handed back without running them&lt;/a&gt;. It streams, but every capability that depends on the model answers unknown, which is the right answer for a server that can run whatever model you load into it. Its first release waits on that live run against a GPU server.&lt;/p&gt;&#10;</description></item><item><title>colophon</title><link>https://phpboyscout.uk/showcase/colophon/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/colophon/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;When a release merge request merges, something has to decide which commit gets the tag. The usual candidates are the merge commit, the squash commit or the head the merge request recorded, and under GitLab&amp;rsquo;s automatic rebase that recorded head can be a commit that never made it onto the branch at all. Nothing fails when that happens&amp;hellip; the tag just quietly points somewhere the branch never went.&lt;/p&gt;&#10;&lt;p&gt;So I went and counted. Across my own Go modules on 1 August 2026, 9 of 231 recent tags weren&amp;rsquo;t on their default branch.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="everything-comes-from-the-commits"&gt;Everything comes from the commits&#10;&lt;/h3&gt;&lt;p&gt;colophon reads commits and commit messages, and nothing else. It never takes an answer from a merge request, an issue or anything else the forge holds. The version is worked out by a pure function over what has landed since the last tag (no clock, no network, no forge), the changelog is written from the same commits, and when a release merges, the tag goes on the commit found on the target branch itself rather than whatever the merge request says it merged.&lt;/p&gt;&#10;&lt;p&gt;That&amp;rsquo;s why &lt;code&gt;plan&lt;/code&gt; works from anywhere. Point it at any checkout, on any forge or none, with no credentials at all, and it tells you what version your commits have earned and which commits decided it. It&amp;rsquo;s also the easiest way to compare colophon against whatever a project releases with today, because it writes nothing.&lt;/p&gt;&#10;&lt;p&gt;The forge is where colophon &lt;em&gt;writes&lt;/em&gt;: the release branch and its merge request, the tag, the release, and the odd comment. It just never reads its answers back from there.&lt;/p&gt;&#10;&lt;h3 id="the-release-in-four-commands"&gt;The release, in four commands&#10;&lt;/h3&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;Command&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;plan&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Works out the next version and the commits that earned it. Writes nothing and needs no credentials.&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;propose&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Re-cuts the release branch from its target, writes the changelog and opens (or updates) the release merge request.&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;publish&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Once that request merges, resolves the commit that actually landed on the target branch, tags it and creates the release. Safe to re-run.&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;release&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Creates the release for a tag that already exists, which is the way back when colophon can&amp;rsquo;t release itself.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The version a release carries comes from the changelog at the tagged commit, never from the merge request&amp;rsquo;s description, so a tag and its version can&amp;rsquo;t drift apart. The &lt;a class="link" href="https://colophon.phpboyscout.uk/explanation/concepts/what-actually-landed/" target="_blank" rel="noopener"&#10; &gt;docs explain what &amp;ldquo;actually landed&amp;rdquo; means&lt;/a&gt; in full.&lt;/p&gt;&#10;&lt;h3 id="trailers-saying-what-a-commit-means"&gt;Trailers: saying what a commit means&#10;&lt;/h3&gt;&lt;p&gt;Because the commits are the only input, anything you want to tell colophon goes in a commit message, mostly as a &lt;a class="link" href="https://colophon.phpboyscout.uk/reference/trailers/" target="_blank" rel="noopener"&#10; &gt;trailer&lt;/a&gt; in the last paragraph:&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;Annotation&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;feat:&lt;/code&gt;, &lt;code&gt;fix:&lt;/code&gt;, &lt;code&gt;type!:&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;The Conventional Commits subject decides the bump and the changelog entry. &lt;code&gt;perf&lt;/code&gt; and &lt;code&gt;refactor&lt;/code&gt; release a patch too.&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;BREAKING CHANGE:&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Marks the change breaking. Below 1.0 that&amp;rsquo;s held to a minor bump, because declaring a stable API should be a decision rather than something a footer does to you.&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;Release-As:&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Sets the version outright, which is how a project promotes itself to &lt;code&gt;1.0.0&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;code&gt;Release-Note:&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Adds a line of prose to the release notes (a fenced &lt;code&gt;release-note&lt;/code&gt; block takes anything longer).&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;Updates:&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Tells an issue, a merge request or a wiki page what became of the work once the commit lands, such as &lt;code&gt;Updates: #13 shipped&lt;/code&gt; or a spec page moving to &lt;code&gt;IMPLEMENTED&lt;/code&gt;.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;(A bare &lt;code&gt;Closes #13&lt;/code&gt; at the bottom doesn&amp;rsquo;t knock out the trailers above it, which it would for &lt;code&gt;git interpret-trailers&lt;/code&gt;. colophon reads a little more than git does there, on purpose.)&lt;/p&gt;&#10;&lt;h3 id="after-the-merge-apply-and-announce"&gt;After the merge: apply and announce&#10;&lt;/h3&gt;&lt;p&gt;Two more verbs run once the work has landed.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;apply&lt;/code&gt; runs on every push to the target branch and carries out the &lt;code&gt;Updates:&lt;/code&gt; trailers that push brought in, commenting on an issue or merge request, or setting a wiki page&amp;rsquo;s status. It never decides for itself that something is finished. A record changes because a commit said so, and each comment carries a key naming the commit, so a retried pipeline posts nothing twice.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;announce&lt;/code&gt; tells people a release happened, through whichever adapters the project&amp;rsquo;s &lt;code&gt;.colophon.yaml&lt;/code&gt; opts into. There are two today. &lt;code&gt;discord&lt;/code&gt; posts the release to a channel, and the headline projects in the estate announce on the community Discord that way (a Slack adapter is coming soon). &lt;code&gt;file&lt;/code&gt; appends the release to a YAML or JSON file in another repository and commits it. The &lt;code&gt;file&lt;/code&gt; adapter is how this site&amp;rsquo;s &lt;a class="link" href="https://phpboyscout.uk/projects/changelog/" &gt;changelog&lt;/a&gt; gets fed, and it&amp;rsquo;s how the version at the top of this page stays current. Announcing is the one thing colophon does that isn&amp;rsquo;t idempotent: a webhook can&amp;rsquo;t be read back, so a retried Discord announcement goes out twice, and the docs say so rather than pretending otherwise.&lt;/p&gt;&#10;&lt;h3 id="in-a-pipeline"&gt;In a pipeline&#10;&lt;/h3&gt;&lt;p&gt;Nobody wires those verbs up by hand. cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/automation/colophon/" target="_blank" rel="noopener"&#10; &gt;colophon component&lt;/a&gt; turns them into pipeline jobs, and 118 projects include it. It also adds the parts that only make sense in a pipeline:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;publish runs ahead of propose, as the decision below needs.&lt;/li&gt;&#10;&lt;li&gt;A check on the release merge request fails it if it carries anything but the changelog files colophon writes, because under a fast-forward merge any other commit on that branch would end up tagged.&lt;/li&gt;&#10;&lt;li&gt;A project with binaries gets its release on the tag pipeline instead, carrying links to what &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/goreleaser/" target="_blank" rel="noopener"&#10; &gt;cicd&amp;rsquo;s goreleaser component&lt;/a&gt; built.&lt;/li&gt;&#10;&lt;li&gt;After each Go tag, the Go module proxy is asked about it once, so pkg.go.dev and the family checks in &lt;a class="link" href="https://phpboyscout.uk/showcase/the-pipeline/" &gt;the pipeline&lt;/a&gt; see the release within a minute.&lt;/li&gt;&#10;&lt;li&gt;Announcements default to this blog&amp;rsquo;s release feed, and a failed announcement can be re-sent with one button.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Build, not fork.&lt;/strong&gt; releaser-pleaser was releasing 96 of my projects when I started, it had had two human commits in its last sixty, and a fix I&amp;rsquo;d written for exactly this bug sat upstream unacknowledged. A fork would have meant maintaining two things instead of one. &lt;em&gt;What it cost:&lt;/em&gt; I own a release tool now, and the library it stands on is no small thing (go/forge alone is roughly four times the size of the tool it replaced). The reasoning is in &lt;a class="link" href="https://gitlab.com/phpboyscout/colophon/-/wikis/specs/0001-a-release-orchestrator-we-own" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Sequencing over parity.&lt;/strong&gt; Parity with releaser-pleaser was never the goal. I own every project that uses it, so where releaser-pleaser&amp;rsquo;s behaviour was just &lt;em&gt;its&lt;/em&gt; answer, colophon was free to pick a different one. &lt;em&gt;What it cost:&lt;/em&gt; a release tool&amp;rsquo;s bugs become permanent tags, so the rollout went cicd first, then the forge family, then everything else, and never as a flag day.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Forge-independent.&lt;/strong&gt; It was designed from the start to talk to forges only through go/forge, so GitLab, GitHub, Gitea, Codeberg and Bitbucket all work through their adapters, and a forge it has never heard of can be added as somebody else&amp;rsquo;s module. &lt;em&gt;What it cost:&lt;/em&gt; only GitLab is proven by daily use (it&amp;rsquo;s what this estate runs on), and I&amp;rsquo;d rather say that up front than imply otherwise.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Publish first.&lt;/strong&gt; Publish runs before propose, as separate jobs. Between a release merging and its tag existing, colophon would happily work out the same release all over again, so publish has to close that window first. They&amp;rsquo;re separate jobs because propose only sees the new tag if its checkout is fetched after publish pushed it. &lt;em&gt;What it cost:&lt;/em&gt; a pipeline rule that&amp;rsquo;s easy to break by folding the two together, so colophon also refuses to propose a release whose commit is already at the tip.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Exit 0 means it worked.&lt;/strong&gt; &lt;code&gt;apply&lt;/code&gt; used to report a failed instruction and still exit zero, and a job went green on a 404 because of it. The first fix on the table was an opt-in flag so nobody&amp;rsquo;s pipeline would go red on upgrade&amp;hellip; and I dropped it for a plain non-zero exit, leaving the colour of the pipeline to &lt;code&gt;allow_failure&lt;/code&gt;. &lt;em&gt;What it cost:&lt;/em&gt; upgrading turned some green pipelines red, on purpose. &lt;a class="link" href="https://phpboyscout.uk/exit-0-is-the-strongest-claim-a-program-can-make/" &gt;The whole story is here&lt;/a&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;116 of the estate&amp;rsquo;s 145 active projects release through it (measured on 10 October 2026), and nothing runs releaser-pleaser any more. The rest are the Rust crates, which still use release-plz, and things that never cut a version at all, like the sites and this blog.&lt;/li&gt;&#10;&lt;li&gt;colophon cuts its own releases, so every tag on its repository is one it resolved itself.&lt;/li&gt;&#10;&lt;li&gt;The CLI&amp;rsquo;s contract is held by a feature suite that runs the real command code against a mocked forge (&lt;a class="link" href="https://gitlab.com/phpboyscout/colophon/-/wikis/specs/0020-the-feature-suite-drives-the-cli" target="_blank" rel="noopener"&#10; &gt;spec 0020&lt;/a&gt;).&lt;/li&gt;&#10;&lt;li&gt;Releases across the estate announce themselves to this site through the &lt;code&gt;file&lt;/code&gt; adapter, which is where the &lt;a class="link" href="https://phpboyscout.uk/projects/changelog/" &gt;changelog&lt;/a&gt; and the latest-releases list on the home page come from.&lt;/li&gt;&#10;&lt;li&gt;The headline projects announce each release on the community Discord through the &lt;code&gt;discord&lt;/code&gt; adapter.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if your project uses Conventional Commits, releases through a merge or pull request, and you want the tag on the commit that actually merged, whichever forge you&amp;rsquo;re on. It isn&amp;rsquo;t tied to a language either: all it asks for is the commit convention and a &lt;code&gt;CHANGELOG.md&lt;/code&gt; it maintains for you. On anything other than GitLab, try &lt;code&gt;plan&lt;/code&gt; first. It writes nothing, and support there comes from the go/forge adapters rather than from me using it every day. And don&amp;rsquo;t use it yet if you need a stable CLI. It&amp;rsquo;s pre-1.0, so pin the version.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Rust is next. The Rust crates still release through release-plz, and the decision to move them to colophon was made on 5 October (&lt;a class="link" href="https://gitlab.com/phpboyscout/colophon/-/wikis/specs/0027-release-a-rust-crate" target="_blank" rel="noopener"&#10; &gt;spec 0027&lt;/a&gt;). Colophon&amp;rsquo;s half has landed, and the publishing to crates.io and the semver check that go with it are cicd&amp;rsquo;s to build (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/work_items/69" target="_blank" rel="noopener"&#10; &gt;cicd#69&lt;/a&gt;).&lt;/p&gt;&#10;</description></item><item><title>config</title><link>https://phpboyscout.uk/showcase/config/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/config/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Most configuration libraries merge every source into one big map: files, environment variables, flags, defaults. That works right up until you need to know where a value came from, because the merge throws that away. And it really goes wrong when the library writes config back by saving that merged map. Comments vanish, the order goes, defaults the user never wrote get pinned into the file, and a password that came from an environment variable lands on disk. Viper&amp;rsquo;s old write path did exactly that, and it&amp;rsquo;s the reason go/config exists.&lt;/p&gt;&#10;&lt;p&gt;The editing half is measurable. Across eight real config files, editing one key at a time for 70 edits in all, &lt;a class="link" href="https://yamldoc.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;yamldoc&lt;/a&gt; changed exactly one line every time and lost no comments. The usual YAML libraries got there 24 times out of 70.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="one-store-and-it-remembers"&gt;One store, and it remembers&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://config.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/config&lt;/a&gt; reads every source into one store, in the order you add them, and records which source supplied each value as it goes, so you can ask it to explain where a setting came from. Reads come from a frozen snapshot, so a group of related reads never mixes values from before and after a reload, and a reload that fails is rejected while the last good config stays live.&lt;/p&gt;&#10;&lt;h3 id="writes-go-back-where-they-came-from"&gt;Writes go back where they came from&#10;&lt;/h3&gt;&lt;p&gt;A change is written to the file that already owns that key, and never to an environment variable or a flag. yamldoc does the editing: it changes only the bytes of the edit, then re-reads its own output and checks it before anything is saved.&lt;/p&gt;&#10;&lt;h3 id="a-module-per-format-and-per-backend"&gt;A module per format and per backend&#10;&lt;/h3&gt;&lt;p&gt;Only YAML is built in. Everything else is an adapter of its own, so you compile only what you use: seven formats (JSON, TOML and HCL read and write; XML, dotenv, INI and Java properties read only), four filesystems, three object stores (S3, Google Cloud Storage and Azure Blob), and eleven key-value and secrets backends, from Consul, etcd and Vault to the cloud secret managers and the macOS keychain. An adapter is handed a client you&amp;rsquo;ve already built and never holds credentials of its own.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;One owner for config.&lt;/strong&gt; A single store owns all config input and output (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/wikis/specs/0002-store-architecture" target="_blank" rel="noopener"&#10; &gt;spec 0002&lt;/a&gt;), instead of bolting a writer onto the old container, which would have needed locks and fingerprints only because three components shared the files. Replacing Viper had been estimated at 8,000 to 12,000 lines of work; once the store existed, the code left to replace was 781. &lt;em&gt;What it cost:&lt;/em&gt; a breaking release, and real porting at a dozen call sites in go-tool-base. &lt;a class="link" href="https://phpboyscout.uk/the-writer-i-added-and-the-viper-i-deleted/" &gt;The writer I added and the Viper I deleted&lt;/a&gt; tells it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Its own filesystem interface.&lt;/strong&gt; A six-method interface config defines for itself, over keeping afero (23% of the dependency graph on its own), the standard library&amp;rsquo;s read-only one, or one that can&amp;rsquo;t be swapped out in tests (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/wikis/specs/0003-filesystem-abstraction" target="_blank" rel="noopener"&#10; &gt;spec 0003&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; afero users wrap it themselves, or add the adapter.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A module per format.&lt;/strong&gt; One module for each format and each remote system, never a bundle (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/wikis/specs/0004-non-yaml-format-adapters" target="_blank" rel="noopener"&#10; &gt;spec 0004&lt;/a&gt;, &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/wikis/specs/0005-dynamic-backend-adapters" target="_blank" rel="noopener"&#10; &gt;spec 0005&lt;/a&gt;), so nobody compiles every parser and cloud SDK to read one file. &lt;em&gt;What it cost:&lt;/em&gt; twenty-seven modules to release, and every config core release becomes a release of all of them. &lt;a class="link" href="https://phpboyscout.uk/a-format-per-module-not-a-fatter-config-core/" &gt;A format per module, not a fatter config core&lt;/a&gt; is the argument.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A YAML engine of its own.&lt;/strong&gt; yamldoc moved off the parser it was built on to an engine it owns (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/yamldoc/-/wikis/specs/0008-an-owned-yaml-engine" target="_blank" rel="noopener"&#10; &gt;yamldoc spec 0008&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; an edit is four to seven times slower than the plain YAML libraries (about 4 ms against 0.6 ms on a 12 KB file), and by its own docs&amp;rsquo; account, it&amp;rsquo;s young. &lt;a class="link" href="https://phpboyscout.uk/yaml-has-comments-for-a-reason/" &gt;YAML has comments for a reason&lt;/a&gt; is the launch.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt;, &lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt;, keryx, krites and phpbotscout all read their config through it, and go-tool-base uses twenty of the adapters.&lt;/li&gt;&#10;&lt;li&gt;385 releases across the 28 modules since July 2026, with the core at v0.20. Twenty-seven adapters releasing against one core is easy to get out of step, so every adapter&amp;rsquo;s release merge request runs cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-core-currency/" target="_blank" rel="noopener"&#10; &gt;go-core-currency&lt;/a&gt; check, which flags a release still built against an older core.&lt;/li&gt;&#10;&lt;li&gt;yamldoc edits real config files one line at a time, measured above, in colophon and keryx as well as config itself.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if your tool layers config from several places, needs to say where a value came from, writes changes back, or reloads while it runs.&lt;/p&gt;&#10;&lt;p&gt;Don&amp;rsquo;t, if you have one file, a few environment variables, no write-back and no reload: the README says plainly that Viper serves that well. It also has limits worth knowing: there are no transactions across sources, writes aren&amp;rsquo;t coordinated between processes, validation understands only required fields and enums, and a key with a dot in it can&amp;rsquo;t be reached. Explaining a value prints the value, secrets included, so mind where that output goes. yamldoc is strict, so a sloppy file fails, and its edits are slower on big files.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Loading a Kubernetes ConfigMap or Secret mount as one layer per file is next, along with yamldoc&amp;rsquo;s release qualification and a few more editing operations for comments, tags and anchors.&lt;/p&gt;&#10;</description></item><item><title>controls</title><link>https://phpboyscout.uk/showcase/controls/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/controls/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;A long-running Go program usually runs several things at once: web servers, gRPC servers, background workers. They all have to start, report whether they&amp;rsquo;re healthy, restart when they fall over, and shut down in a sensible order without hanging. Some of them are there for the life of the program, and some come and go while it runs: a worker per tenant, a subscription per topic. Most teams write that supervisor again for every project, and then write the glue that plugs each kind of server into it again too.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="the-controller-the-services-a-program-needs"&gt;The controller: the services a program needs&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://controls.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;controls&lt;/a&gt; has two layers. The controller looks after a fixed set of services, registered before it starts. It starts them all at once, gathers their health checks into status, liveness and readiness reports, restarts a failed service with growing waits up to a limit, and stops them in reverse order of registration within a time limit. A stop that hangs is abandoned at the deadline and named in a warning, so a shutdown always finishes. It depends on nothing but go/errors.&lt;/p&gt;&#10;&lt;h3 id="the-supervisor-workers-that-come-and-go"&gt;The supervisor: workers that come and go&#10;&lt;/h3&gt;&lt;p&gt;The supervisor looks after children that attach and detach while the program runs, under the same restart rules, and stops them all at once. A supervisor is itself a service, so it registers with the controller like anything else: the controller supervises the supervisor, and the supervisor supervises the children. A program has one controller and as many supervisors as it has changing sets.&lt;/p&gt;&#10;&lt;p&gt;What separates the two layers is what a failure means (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/controls/-/wikis/specs/0002-supervision-for-children-that-come-and-go" target="_blank" rel="noopener"&#10; &gt;spec 0002&lt;/a&gt;). A registered service is required, so one that&amp;rsquo;s unready takes the whole program out of rotation. An attached child isn&amp;rsquo;t. When it fails for good, the supervisor stays ready, reports degraded, and hands the failure to the code that attached the child, which knows whether it mattered.&lt;/p&gt;&#10;&lt;h3 id="common-services-registered-in-one-call"&gt;Common services, registered in one call&#10;&lt;/h3&gt;&lt;p&gt;The services most programs run already know how to plug in, so wiring them up is a call rather than a page of start and stop code:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/transport-stack/" &gt;transport&lt;/a&gt; registers an HTTP server, a gRPC server or a REST gateway with a controller in one call, which brings its start, stop and status with it. It also turns the controller&amp;rsquo;s health reports into ready-made health, liveness and readiness endpoints, and into the standard gRPC health service.&lt;/li&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/messaging/" &gt;messaging&lt;/a&gt;&amp;rsquo;s bus has the start, stop and readiness methods a controller expects, and it uses both layers: the bus is a service of the controller, and each subscription is a child of a supervisor the bus owns. A restart replaces that whole set of subscriptions at once. The bus also offers a readiness check that reports degraded, by name, when a subscription has failed for good.&lt;/li&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://nats.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/nats&lt;/a&gt; registers an embedded NATS server, and a client connected to it, with their start, stop, readiness and liveness wired up.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;One owner for signals.&lt;/strong&gt; The supervisor stopped catching Ctrl-C by default: a standalone program asks for it, and the old opt-out was deleted outright (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/controls/-/wikis/specs/0001-single-owner-signal-handling" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;), instead of every framework tool having to remember to switch it off. &lt;em&gt;What it cost:&lt;/em&gt; a break for anyone relying on the old default, and a survey found nobody was. It fixed a live bug on the way.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Registered means required.&lt;/strong&gt; A failed child never makes its supervisor unready, at any proportion, including all of them (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/controls/-/wikis/specs/0002-supervision-for-children-that-come-and-go" target="_blank" rel="noopener"&#10; &gt;spec 0002&lt;/a&gt;). Otherwise one dead worker would reach the whole program&amp;rsquo;s health through the supervisor&amp;rsquo;s own registration. &lt;em&gt;What it cost:&lt;/em&gt; a child that really was needed has to be noticed by the code that attached it, which then makes itself unready.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Generations.&lt;/strong&gt; Five recorded failures across the estate came from a restart reusing something that could only be used once. Now stopping always finishes within its limit, and an old and a new copy never run at the same time (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/controls/-/wikis/specs/0004-a-generation-is-the-unit-a-restart-replaces" target="_blank" rel="noopener"&#10; &gt;spec 0004&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; leftover work from a stopped copy may still be running, so it&amp;rsquo;s cut off and refused any further access. controls ships an analyzer that names the line where a service holds on to something a restart can&amp;rsquo;t reuse, and cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-singleuse/" target="_blank" rel="noopener"&#10; &gt;go-singleuse component&lt;/a&gt; runs it on merge requests as an advisory check. No project includes it yet.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Say why it stopped.&lt;/strong&gt; A controller now records why each shutdown happened and who failed on the way, and its failure events never hold a service up (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/controls/-/wikis/specs/0008-a-controller-says-why-it-stopped" target="_blank" rel="noopener"&#10; &gt;spec 0008&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a reader that stops taking events can delay shutdown by the rest of the time limit. It&amp;rsquo;s on the main branch, not yet in a release.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;Ten public projects run under controls, including &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt;, &lt;a class="link" href="https://phpboyscout.uk/showcase/messaging/" &gt;messaging&lt;/a&gt;, keryx, krites, phpbotscout and the &lt;a class="link" href="https://phpboyscout.uk/showcase/transport-stack/" &gt;transport stack&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;17 releases of controls since July 2026, and over 200 of its own test functions.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if a Go program runs several long-lived services, or workers that come and go, and you want them started, watched, restarted and stopped properly, without a framework around them.&lt;/p&gt;&#10;&lt;p&gt;It serves no health endpoints itself (transport turns its reports into them), doesn&amp;rsquo;t order startup or make one service wait for another, and a crash in a service&amp;rsquo;s start code takes the program down. A supervisor can&amp;rsquo;t be reconfigured while it runs, restart waits double with no randomness, and there are no metrics or tracing. An HTTP or gRPC server registered through transport can&amp;rsquo;t be given a restart policy yet, because a Go server can&amp;rsquo;t serve again once it has been shut down.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Letting a controller stop when a service gives up, surviving a crash in a service&amp;rsquo;s start, finishing cleanly when the work is done, and one event queue for the controller and its supervisor.&lt;/p&gt;&#10;</description></item><item><title>Docs at estate scale</title><link>https://phpboyscout.uk/showcase/docs-at-estate-scale/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/docs-at-estate-scale/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;There are 146 active projects in the estate, and every one of them has docs that can go stale without anyone touching them. The case that made it obvious: three of krites&amp;rsquo; pages told readers to run a command that had stopped working when it gained subcommands. The merge request that fixed those pages ran only the security jobs and the docs build, because a docs-only change skips the code gates, so nothing would have caught the next one either (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0068-docs-verify-component" target="_blank" rel="noopener"&#10; &gt;spec 0068&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;The readers changed too. By September the docs sites were 71% of the estate&amp;rsquo;s search impressions, AI crawlers were fetching hundreds of pages a week, and assistants were fetching keryx&amp;rsquo;s Instagram how-to on users&amp;rsquo; behalf when Google sent it nobody (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0089-every-docs-site-carries-a-map-for-machine-readers" target="_blank" rel="noopener"&#10; &gt;spec 0089&lt;/a&gt;). So the docs have to be right, findable, and readable by a machine that will quote them.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="one-site-per-project-built-by-one-component"&gt;One site per project, built by one component&#10;&lt;/h3&gt;&lt;p&gt;73 public projects have their own docs site, at &lt;code&gt;&amp;lt;name&amp;gt;.phpboyscout.uk&lt;/code&gt; or &lt;code&gt;&amp;lt;module&amp;gt;.go.phpboyscout.uk&lt;/code&gt;, built with Zensical by cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/pages/zensical-pages/" target="_blank" rel="noopener"&#10; &gt;zensical-pages component&lt;/a&gt;. Each one builds on every docs change and deploys from main. The 41 adapter modules (each config backend, chat provider and forge adapter) deliberately have no site of their own and are documented on their family&amp;rsquo;s. The toolchain lives in a &lt;a class="link" href="https://phpboyscout.uk/showcase/build-images/" &gt;build image&lt;/a&gt; rather than each repository, after hand-maintained lock files left 18 update merge requests stuck at once (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0039-bake-zensical-into-image" target="_blank" rel="noopener"&#10; &gt;spec 0039&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="written-for-the-machines-too"&gt;Written for the machines too&#10;&lt;/h3&gt;&lt;p&gt;Because every site is built by the same component, an improvement lands everywhere without a change in any repository. The component writes an &lt;code&gt;llms.txt&lt;/code&gt; map of each site for AI agents (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0089-every-docs-site-carries-a-map-for-machine-readers" target="_blank" rel="noopener"&#10; &gt;spec 0089&lt;/a&gt;), adds structured data naming the site, the software and its author on every page (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0091-every-docs-page-names-its-site-software-and-author" target="_blank" rel="noopener"&#10; &gt;spec 0091&lt;/a&gt;), honours the exclusions Zensical ignores, so scaffold pages stop being indexed (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0090-exclude-docs-is-honoured-by-the-component" target="_blank" rel="noopener"&#10; &gt;spec 0090&lt;/a&gt;), and fills in the &amp;ldquo;edit this page&amp;rdquo; link, which had pointed every site at a branch that doesn&amp;rsquo;t exist (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0092-the-component-fills-in-edit-uri-and-language" target="_blank" rel="noopener"&#10; &gt;spec 0092&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="the-same-shape-everywhere"&gt;The same shape everywhere&#10;&lt;/h3&gt;&lt;p&gt;Every site follows Diátaxis: tutorials to learn from, how-to guides for a task, reference to look things up, and explanation for the why. 62 of the 73 sites have all four. How-to and reference pages open with the answer, in under a hundred words before the first example. Tutorials live in each project&amp;rsquo;s own docs, and when the blog runs one, it&amp;rsquo;s a copy that points back to the docs as the original. A tool built on &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; gets the layout generated for it. The rule itself is a skill every agent session reads (&lt;a class="link" href="https://gitlab.com/phpboyscout/claude-code-plugins/-/blob/main/plugins/phpboyscout-common/skills/diataxis-docs/SKILL.md" target="_blank" rel="noopener"&#10; &gt;diataxis-docs&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;Specs don&amp;rsquo;t live in the docs. They&amp;rsquo;re decision records, so they go on each project&amp;rsquo;s wiki, and the docs link to them. The test for what moves is whether a page would need updating when the code changes.&lt;/p&gt;&#10;&lt;h3 id="checked-against-the-code"&gt;Checked against the code&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://cicd.phpboyscout.uk/reference/quality/docs-verify/" target="_blank" rel="noopener"&#10; &gt;docs-verify&lt;/a&gt; runs a project&amp;rsquo;s own docs check on every merge request, with no filter on which files changed, because the code change that breaks a page doesn&amp;rsquo;t touch the page. The component supplies the guarantee and the project supplies the check. &lt;a class="link" href="https://docscheck.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;docscheck&lt;/a&gt; is one such check: it reads the command examples in a tool&amp;rsquo;s docs and fails when one names a subcommand that doesn&amp;rsquo;t exist. It&amp;rsquo;s narrow on purpose. The obvious broader rule &amp;ldquo;found 15 problems on a corpus whose real defect count was 3.&amp;rdquo; Rust projects get the same from &lt;code&gt;cargo doc&lt;/code&gt; treating warnings as errors, on all 12.&lt;/p&gt;&#10;&lt;p&gt;Beyond the gates, two agents in the estate&amp;rsquo;s &lt;a class="link" href="https://phpboyscout.uk/showcase/agent-skills/" &gt;plugin marketplace&lt;/a&gt; check docs on demand: one audits every signature, flag, config key and snippet against the source and fixes nothing, and the other writes to the Diátaxis shape and finishes with a merge request.&lt;/p&gt;&#10;&lt;h3 id="answerable-by-the-bot"&gt;Answerable by the bot&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/phpbotscout/" &gt;phpbotscout&lt;/a&gt; answers support questions from these sites, with citations, so it doubles as a test of whether they answer anything. In August it reviewed the docs of 48 projects. Across 829 pages it found reference was the weakest part and mattered most, that the &amp;ldquo;why&amp;rdquo; had been left in specs the bot can&amp;rsquo;t read, that limitations weren&amp;rsquo;t written down, and that sections didn&amp;rsquo;t stand alone when retrieved one heading at a time. Every one of those reviews was closed the next day. keryx gained a reference section, a limitations page and its first tutorial. config gained six reference pages documenting all 21 of its errors.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;One map writer.&lt;/strong&gt; The alternative was &amp;ldquo;66 merge requests to add a file every site derives identically&amp;rdquo;, and a hand-written map drifts (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0089-every-docs-site-carries-a-map-for-machine-readers" target="_blank" rel="noopener"&#10; &gt;spec 0089&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; the generator lives inside the component&amp;rsquo;s template, with a self-test that its copy matches the canonical one. A full-text dump for language models was turned down as &amp;ldquo;a training-data gift with no evidence anyone fetches it&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Builds follow docs changes.&lt;/strong&gt; A site only rebuilds when its own docs change. &lt;em&gt;What it cost:&lt;/em&gt; a component improvement reaches a site only on that site&amp;rsquo;s next docs change. 21 module sites still have neither the map nor the structured data.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A narrow checker.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; docscheck checks command paths only, so a renamed flag or a wrong argument still gets through.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Tutorials moved into the docs.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; every blog tutorial was copied back and rewritten in the tutorial register.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;73 docs sites, all live, with 1,652 pages between them. The largest are go-tool-base&amp;rsquo;s (268), keryx&amp;rsquo;s (128), and config&amp;rsquo;s and cicd&amp;rsquo;s (72 each).&lt;/li&gt;&#10;&lt;li&gt;61 sites have at least one tutorial, and 38 have a limitations page.&lt;/li&gt;&#10;&lt;li&gt;52 sites already carry the AI map, and 50 the structured data.&lt;/li&gt;&#10;&lt;li&gt;48 bot-led reviews, then 72 follow-up passes comparing each README and its docs with each other and with the code, 44 of them finished.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="borrow-it-when-and-when-not-to"&gt;Borrow it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Borrow it if you run more than a handful of projects and want their docs consistent without editing each one: put the build in one shared component and every site gets each improvement. Diátaxis and an answer-first rule carry over to any docs tool.&lt;/p&gt;&#10;&lt;p&gt;Only krites has a docs check wired into docs-verify so far, and docscheck checks command paths only. No site has automatic link checking: links are checked by the auditing agent, and after a restructure. Zensical has no redirects of its own, so a renamed page needs a redirects file. Two sites, cicd&amp;rsquo;s and phpbotscout&amp;rsquo;s, name their GitLab repository as their own address, so search engines and the map point there rather than at the site. go.phpboyscout.uk is hand-written and has no map.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Rebuilding the 21 sites that predate the map and structured data (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/work_items/75" target="_blank" rel="noopener"&#10; &gt;cicd#75&lt;/a&gt;), pointing cicd&amp;rsquo;s and phpbotscout&amp;rsquo;s sites at their own addresses (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/work_items/76" target="_blank" rel="noopener"&#10; &gt;cicd#76&lt;/a&gt;, &lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/work_items/22" target="_blank" rel="noopener"&#10; &gt;phpbotscout#22&lt;/a&gt;), and the 28 follow-up passes still open, such as &lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/work_items/25" target="_blank" rel="noopener"&#10; &gt;keryx#25&lt;/a&gt; and &lt;a class="link" href="https://gitlab.com/phpboyscout/go/config/-/work_items/7" target="_blank" rel="noopener"&#10; &gt;config#7&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>errors</title><link>https://phpboyscout.uk/showcase/errors/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/errors/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;The estate used to sit on cockroachdb/errors, chosen for its features and for being well maintained. The features held up and the maintenance didn&amp;rsquo;t: a bug that hid user hints underneath a joined error went unanswered for months, and a docs typo sat open for two years while a fix CockroachDB itself needed merged in five days (&lt;a class="link" href="https://phpboyscout.uk/merged-in-five-days-open-in-seven-hundred/" &gt;Merged in five days, open in seven hundred&lt;/a&gt; tells that story).&lt;/p&gt;&#10;&lt;p&gt;And it was expensive for what it gave. The estate called about ten of its functions, and for those paid 46 of go/forge&amp;rsquo;s 376 transitive packages, a deprecated protobuf library among them (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/errors/-/wikis/specs/0001-an-errors-package-we-own" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="the-same-names-nothing-underneath"&gt;The same names, nothing underneath&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://errors.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/errors&lt;/a&gt; keeps the names and signatures the estate was already using, so moving across was mostly a change of import path. It imports the standard library and nothing else, and a test fails the build if that ever changes. On top of that it adds what was missing: hints a user can act on, structured attributes that carry straight into logs, sentinel errors that don&amp;rsquo;t capture a stack, and a join that behaves the way the standard library&amp;rsquo;s does.&lt;/p&gt;&#10;&lt;h3 id="a-handler-that-doesnt-exit-for-you"&gt;A handler that doesn&amp;rsquo;t exit for you&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://errorhandling.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/errorhandling&lt;/a&gt; sits on top for command-line tools: it reports an error to the user with its hints, carries an exit code on the error itself, and hands that code back to &lt;code&gt;main&lt;/code&gt; instead of exiting the process from inside a library.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Standard library only.&lt;/strong&gt; The core depends on nothing else, not even &amp;ldquo;a few&amp;rdquo; dependencies. &lt;em&gt;What it cost:&lt;/em&gt; a narrower API (attributes reuse the standard logger&amp;rsquo;s type), and an errors package the estate owns outright.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Source-compatible.&lt;/strong&gt; The names stayed the same rather than being redesigned to taste. The spike that proved it moved go/forge across with one &lt;code&gt;sed&lt;/code&gt; over 19 files and no code changes. &lt;em&gt;What it cost:&lt;/em&gt; keeping some names I wouldn&amp;rsquo;t have picked.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A fixed join.&lt;/strong&gt; Join behaves properly instead of copying the old bug. &lt;em&gt;What it cost:&lt;/em&gt; a behaviour change at four call sites across the estate.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Return the code, don&amp;rsquo;t exit.&lt;/strong&gt; The handler reports and returns an exit code; it never calls &lt;code&gt;os.Exit&lt;/code&gt; itself (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/errorhandling/-/wikis/specs/0002-the-handler-after-cockroachdb" target="_blank" rel="noopener"&#10; &gt;errorhandling spec 0002&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; every &lt;code&gt;main&lt;/code&gt; has to act on the code it&amp;rsquo;s given, and go-tool-base lost a workaround it had needed to flush output before exiting.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;92 projects in the estate require go/errors, from &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; and &lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt; to 83 go/* modules, and nine use errorhandling.&lt;/li&gt;&#10;&lt;li&gt;cockroachdb/errors no longer appears in a single go.mod on the main branch, directly or indirectly.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you want errors with hints and structured attributes, compatible with the API you probably already know, and you&amp;rsquo;d like your error package to be the one dependency that brings no others.&lt;/p&gt;&#10;&lt;p&gt;It doesn&amp;rsquo;t send errors over the wire yet, so matching an error across a process boundary doesn&amp;rsquo;t work, and there&amp;rsquo;s no OpenTelemetry integration. Stacks are capped at 32 frames, a sentinel has no stack unless you wrap it, and the handler doesn&amp;rsquo;t redact or translate anything. The limitations pages for &lt;a class="link" href="https://errors.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;errors&lt;/a&gt; and &lt;a class="link" href="https://errorhandling.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;errorhandling&lt;/a&gt; have the rest.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Rendering an error onto a trace span is the next piece, and after that a wire codec, so an error can cross a process boundary and still be recognised on the other side.&lt;/p&gt;&#10;</description></item><item><title>Git and forges</title><link>https://phpboyscout.uk/showcase/git-and-forges/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/git-and-forges/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;A program that works with version control talks to two different things. One is the forge, the website and API around the code: its releases, merge requests, issues and wikis. The other is git itself: cloning, committing, branching and tagging. These modules cover both, and neither needs a forge&amp;rsquo;s SDK or a git binary installed.&lt;/p&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://forge.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/forge&lt;/a&gt; has a narrow job: letting a program work with a forge properly, the same way whichever forge it is. It started inside go-tool-base as a thin layer over releases, finding a tool&amp;rsquo;s latest release and downloading its assets so the tool could update itself. Once it was its own module it grew quickly, because programs want far more from a forge than its releases: merge requests, merging, issues, comments, wikis and the rest.&lt;/p&gt;&#10;&lt;p&gt;The hard part is that no two forges do the same thing the same way. GitHub, GitLab, Gitea and Bitbucket each have their own idea of what a release, a merge request or a label is, and Bitbucket simply doesn&amp;rsquo;t have some of them at all. go/forge is one consistent interface over all of that, so a tool written against it doesn&amp;rsquo;t need four code paths, or four vendors&amp;rsquo; SDKs in its build.&lt;/p&gt;&#10;&lt;p&gt;There&amp;rsquo;s a less obvious reason too. A forge&amp;rsquo;s own answers can&amp;rsquo;t always be trusted. On GitLab, the rebase that happens just before a fast-forward merge is never written back to the merge request, so the head commit the merge request records can be one that sits on no branch at all. A release tool that believes it tags the wrong commit, quietly. Measured across one group of my own modules on 1 August 2026, 9 of 231 tags weren&amp;rsquo;t on their default branch (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0010-pull-request-lifecycle" target="_blank" rel="noopener"&#10; &gt;spec 0010&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;On the git side, &lt;a class="link" href="https://repo.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/repo&lt;/a&gt; starts from the fact that git doesn&amp;rsquo;t know what a forge is. It wants a URL and a credential, so cloning from GitHub shouldn&amp;rsquo;t put a GitHub SDK in your build either. It sits on &lt;a class="link" href="https://github.com/go-git/go-git" target="_blank" rel="noopener"&#10; &gt;go-git&lt;/a&gt;, git written in pure Go, which is powerful and raw. Its biggest catch is concurrency: go-git isn&amp;rsquo;t safe to use from more than one goroutine at once, even for reads, so a server or a tool doing several things in parallel can corrupt go-git&amp;rsquo;s caches just by reading. go/repo&amp;rsquo;s headline job is making go-git safe to share.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="a-small-core-and-everything-else-optional"&gt;A small core, and everything else optional&#10;&lt;/h3&gt;&lt;p&gt;Every forge has to do four things: find the latest release, find one by tag, list releases, and download a release asset. Everything beyond that (merge requests, merging, publishing releases, issues, comments, wikis, pages and more, nineteen of them) is an optional capability, each one separately present or absent, and a caller checks before it asks. Adding a new capability never breaks an existing provider, including somebody else&amp;rsquo;s, and the &lt;a class="link" href="https://forge.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;capability matrix&lt;/a&gt; says &lt;em&gt;never&lt;/em&gt; where a platform simply can&amp;rsquo;t do something and &lt;em&gt;not yet&lt;/em&gt; where the code hasn&amp;rsquo;t been written.&lt;/p&gt;&#10;&lt;h3 id="one-module-per-forge"&gt;One module per forge&#10;&lt;/h3&gt;&lt;p&gt;Each forge&amp;rsquo;s SDK lives in an adapter of its own (GitHub, GitLab, Gitea and Codeberg, Bitbucket), plus a plain &amp;ldquo;direct&amp;rdquo; provider for download URLs that needs no SDK at all. The core can&amp;rsquo;t take on any of them: two tests fail the build if a forge SDK, or even an instrumented HTTP stack, reaches its dependency graph. A shared conformance suite holds every adapter to the same contract, and a provider registers itself by name, so a forge I&amp;rsquo;ve never heard of can be added as another person&amp;rsquo;s module.&lt;/p&gt;&#10;&lt;h3 id="careful-with-what-it-trusts"&gt;Careful with what it trusts&#10;&lt;/h3&gt;&lt;p&gt;A token is pinned to the host it was issued for, so a hostile release can&amp;rsquo;t redirect it somewhere else. And &amp;ldquo;which commit actually merged&amp;rdquo; is only ever answered after checking that the target branch contains it, never from the merge request&amp;rsquo;s say-so.&lt;/p&gt;&#10;&lt;h3 id="go-git-made-safe-to-share"&gt;go-git, made safe to share&#10;&lt;/h3&gt;&lt;p&gt;go-git updates its internal caches even when it&amp;rsquo;s only reading, so two goroutines reading the same repository can corrupt those caches. A read-write lock looks like the obvious fix and isn&amp;rsquo;t one, because the reads write too. go/repo&amp;rsquo;s thread-safe repository puts every operation behind one exclusive lock instead, reads included, and the rest of the design makes that lock hard to slip past:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A filesystem handle taken from it keeps taking the lock after it&amp;rsquo;s been passed somewhere else, and so does every file opened through it, so code that knows nothing about the lock still can&amp;rsquo;t touch the working tree unsafely. There&amp;rsquo;s no way to reach the unlocked filesystem underneath.&lt;/li&gt;&#10;&lt;li&gt;A sequence that has to happen as one, such as writing two files and committing them, runs inside a callback that holds the lock for the whole sequence.&lt;/li&gt;&#10;&lt;li&gt;Reaching past the wrapper to go-git itself also happens inside a callback, under the same lock, so even the operations go/repo doesn&amp;rsquo;t wrap stay safe.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;It&amp;rsquo;s safe &lt;em&gt;mostly&lt;/em&gt;, and the edges are deliberate. The lock isn&amp;rsquo;t reentrant, so calling the repository from inside one of its own callbacks deadlocks. A go-git pointer kept after its callback returns, or one handed out at setup time, is back outside the lock. And the lock makes sharing safe rather than faster: operations queue up behind each other.&lt;/p&gt;&#10;&lt;h3 id="a-real-repository-held-in-memory"&gt;A real repository, held in memory&#10;&lt;/h3&gt;&lt;p&gt;go/repo can clone a repository into memory and work on it there: read files, commit, branch, tag and push, with nothing written to disk. A tag is always annotated, is made at a full commit SHA the caller has already settled on rather than whatever a name resolves to, and refuses to overwrite a tag that already exists. It isn&amp;rsquo;t a mock. It&amp;rsquo;s a real git repository, so it also makes a test fixture with no fake behaviour to drift from real git, and nothing to clean up afterwards. The same API works against a checkout on disk.&lt;/p&gt;&#10;&lt;p&gt;A sparse checkout clones a large repository but fills the working tree with only the directories you name, in one option. On a test repository of 300 files it cut the memory a clone held from 12.6 MiB to 5.4 MiB. The history is still fetched in full, so the saving is the working copy.&lt;/p&gt;&#10;&lt;h3 id="the-working-tree-is-an-ordinary-filesystem"&gt;The working tree is an ordinary filesystem&#10;&lt;/h3&gt;&lt;p&gt;The checked-out files are exposed as an afero filesystem, the interface much of the Go ecosystem already reads and writes through, by way of &lt;a class="link" href="https://aferobilly.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;aferobilly&lt;/a&gt;. It&amp;rsquo;s a live view rather than a copy, so a file written through it is a file in the working tree, with no sync step.&lt;/p&gt;&#10;&lt;h3 id="signing-in-to-any-forge-without-importing-one"&gt;Signing in to any forge without importing one&#10;&lt;/h3&gt;&lt;p&gt;Cloning or pushing over HTTPS means sending a username and a token, and each forge expects a different username beside the token: &lt;code&gt;oauth2&lt;/code&gt; on GitLab, &lt;code&gt;x-token-auth&lt;/code&gt; on Bitbucket, and &lt;code&gt;x-access-token&lt;/code&gt; on GitHub, Gitea and Codeberg. So a git library has to know which forge it&amp;rsquo;s talking to. The obvious way to know is to import a forge library, and go/repo used to do exactly that, pulling in a whole release-and-forge package to get two values the caller already had: a name and a token.&lt;/p&gt;&#10;&lt;p&gt;Now the caller passes them in. The forge is a plain name, so an unknown one, a self-hosted or brand-new forge, simply gets the GitHub convention and works without a code change. A test fails the build if any forge package or SDK reaches go/repo&amp;rsquo;s dependencies, so the coupling can&amp;rsquo;t creep back.&lt;/p&gt;&#10;&lt;p&gt;The token is fetched only if it&amp;rsquo;s actually used. SSH takes priority over a token, and reading a token can mean unlocking the system keychain, so a repository set up for SSH must never trigger that prompt. The tests prove it with a token source that crashes if it&amp;rsquo;s ever called.&lt;/p&gt;&#10;&lt;h3 id="and-a-changelog"&gt;And a changelog&#10;&lt;/h3&gt;&lt;p&gt;Beside both sits &lt;a class="link" href="https://changelog.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/changelog&lt;/a&gt;, which builds a changelog from a repository&amp;rsquo;s Conventional Commits, and can read an existing set of release notes back into a structure a program can query.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Every forge, or none.&lt;/strong&gt; No capability ships for one forge alone. GitHub&amp;rsquo;s adapter came out of go-tool-base carrying merge requests, repository creation and file contents, and only its releases came across, because a GitHub-only method would compile, pass its tests and still make the portability claim false (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0001-forge-operations-beyond-releases" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;). A capability lands when all four adapters can do it, or a platform genuinely can&amp;rsquo;t and says so. &lt;em&gt;What it cost:&lt;/em&gt; that GitHub code waited until the other three caught up.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;No recorded head at all.&lt;/strong&gt; The contract has no field for the commit a merge request claims to have merged. The answer comes back only once the target branch is confirmed to contain it (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0010-pull-request-lifecycle" target="_blank" rel="noopener"&#10; &gt;spec 0010&lt;/a&gt;), because checking the commit merely exists isn&amp;rsquo;t enough when the orphaned one still exists too. &lt;em&gt;What it cost:&lt;/em&gt; on a rebased merge request, a walk of up to a hundred commits where one field read would have done.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Credentials, caller&amp;rsquo;s choice.&lt;/strong&gt; The built-in four-step lookup for tokens was deleted outright, and the caller now decides where credentials come from (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0005-credential-source-composition" target="_blank" rel="noopener"&#10; &gt;spec 0005&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a clean break with no shim, felt downstream, in exchange for a core that no longer depends on a credentials library.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Verify the release commit.&lt;/strong&gt; GitHub and GitLab both silently ignore the target commit once a tag exists, so the provider verifies it instead of passing it along, and an absent tag is refused rather than created (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0013-release-creation" target="_blank" rel="noopener"&#10; &gt;spec 0013&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a naming decision reversed after a release had shipped, when short method names collided across all four adapters, and a test now catches that kind of collision.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Merging, but people decide.&lt;/strong&gt; Merging was first ruled out of the library entirely. That got reversed, on the basis that the house rule is about &lt;em&gt;who&lt;/em&gt; merges, not what the code can do (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0024-merging-a-pull-request" target="_blank" rel="noopener"&#10; &gt;spec 0024&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; &amp;ldquo;merge when ready&amp;rdquo; is refused on Bitbucket, where it can&amp;rsquo;t be checked below a premium workspace.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Linked worktrees just work.&lt;/strong&gt; Opening a repository inside a linked worktree (the extra checkout &lt;code&gt;git worktree add&lt;/code&gt; makes) used to hand back a healthy-looking repository that failed later, with an error about missing objects that never mentioned worktrees. The fix is always on rather than an option, because an option would only exist to let someone ask for the broken behaviour (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/repo/-/wikis/specs/0001-linked-worktree-support" target="_blank" rel="noopener"&#10; &gt;repo spec 0001&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a worktree pointing at a repository that&amp;rsquo;s gone now fails when it&amp;rsquo;s opened, which is the better place to fail.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Ask for the narrowest part.&lt;/strong&gt; go/repo is split into ten small roles (reading the tree, committing, branching and so on), so a function that only reads files asks for only that, and a two-method fake can stand in for it in a test. Each role ships a mock, and the build fails if a mock stops matching its role. &lt;em&gt;What it cost:&lt;/em&gt; most of go-git isn&amp;rsquo;t wrapped (fetch, log, merge, status and the rest), so a caller reaches go-git directly through a callback, still under the lock, and gives up the narrow contract while they&amp;rsquo;re there.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt; is built on all of it. It uses all four forge adapters, tags its releases from an in-memory clone, and records each release in this blog with a sparse clone of the one directory it writes to, without a disk or a git binary.&lt;/li&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/phpbotscout/" &gt;phpbotscout&lt;/a&gt; uses go/forge and go/repo together. go/forge finds which repositories across the estate have a docs site, by checking each one for its site&amp;rsquo;s config file, and go/repo shallow-clones each of those into memory so the bot can index the docs.&lt;/li&gt;&#10;&lt;li&gt;skillup uses both too. Its GitLab adapter comes through go-tool-base, and every history question it asks and every commit and push it makes goes through go/repo, with nothing shelled out to a git binary. It often runs inside a linked worktree.&lt;/li&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; uses the forge core, repo and changelog, keryx uses go/forge and go/repo, and krites and sigillum talk to GitLab through go/forge.&lt;/li&gt;&#10;&lt;li&gt;183 releases across the forge family since July 2026, 42 of them the core, plus 10 of go/repo, 10 of go/changelog and 6 of aferobilly.&lt;/li&gt;&#10;&lt;li&gt;Every forge adapter runs the same conformance suite, and every adapter&amp;rsquo;s release merge request runs cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-core-currency/" target="_blank" rel="noopener"&#10; &gt;go-core-currency&lt;/a&gt; check, which flags an adapter about to ship against an older go/forge.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use go/forge if a Go tool needs to read or cut releases, or work with merge requests, on more than one forge, or might one day, and you&amp;rsquo;d rather not carry every vendor&amp;rsquo;s SDK to do it. Use go/repo if a Go program needs to clone, commit or push without a git binary on the machine, needs to share one repository across goroutines, or wants a real repository in memory for its tests.&lt;/p&gt;&#10;&lt;p&gt;Know where the edges are. GitLab is the one proven by daily use; the others are supported by their adapters and tests rather than by me running them in anger. go/forge doesn&amp;rsquo;t retry, back off or cache, it downloads checksums and signatures without verifying them (that&amp;rsquo;s &lt;a class="link" href="https://phpboyscout.uk/showcase/signing-and-trust/" &gt;go/signing&amp;rsquo;s job&lt;/a&gt;), and some forges can&amp;rsquo;t answer some questions at all: Bitbucket can&amp;rsquo;t list releases or find one by tag, and has no issues. go/repo reads files from the current commit only, not another revision, the staging area or uncommitted changes, and named remotes aren&amp;rsquo;t available on the thread-safe repository. The &lt;a class="link" href="https://forge.go.phpboyscout.uk/explanation/limitations/" target="_blank" rel="noopener"&#10; &gt;forge limitations&lt;/a&gt; and &lt;a class="link" href="https://repo.go.phpboyscout.uk/explanation/limitations/" target="_blank" rel="noopener"&#10; &gt;repo limitations&lt;/a&gt; pages have the full lists. Both are pre-1.0, so pin them.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Most of what&amp;rsquo;s left is waiting on other people: merge-when-ready on Bitbucket needs a premium workspace to test against, a panic in the Gitea SDK is written up for its maintainers, and a symlink failure in go-git waits on go-git v6, whose pre-releases already fix most of it.&lt;/p&gt;&#10;</description></item><item><title>go-tool-base</title><link>https://phpboyscout.uk/showcase/go-tool-base/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/go-tool-base/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Most of a Go application isn&amp;rsquo;t the part that&amp;rsquo;s actually yours. It&amp;rsquo;s configuration that layers files, environment and flags and can tell you where a value came from, an update you can trust not to install somebody else&amp;rsquo;s binary, errors a user can act on, a clean way to start, run and stop, and the same handful of commands (init, update, doctor, config, docs) written again for every new thing you build.&lt;/p&gt;&#10;&lt;p&gt;go-tool-base writes those once. It&amp;rsquo;s a batteries-included framework (think Rails or Laravel, for a Go binary), and it started out for command-line tools, because everything starts life as a binary you run. But more than half of what&amp;rsquo;s built on it today doesn&amp;rsquo;t exit when a command finishes. keryx and krites each serve a local web studio from the same binary, phpbotscout runs as a long-lived bot, and Scout has a window and a voice.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="the-usual-commands-already-written"&gt;The usual commands, already written&#10;&lt;/h3&gt;&lt;p&gt;A tool built on go-tool-base starts from one shared bundle of the things every command needs (its name and version, a logger, its config, the filesystem, error handling) and adds its own commands, and the framework builds the rest of the tool around them. The built-in commands arrive as features: update, init, MCP, docs, doctor and changelog are on by default, and AI, config, telemetry and man pages are opt-in. A tool switches each one on or off, and the framework behaves accordingly. A tool without an init command, for instance, is allowed to start with no config file at all, because it has no way to create one.&lt;/p&gt;&#10;&lt;h3 id="a-generator-that-keeps-writing-the-boring-bits"&gt;A generator that keeps writing the boring bits&#10;&lt;/h3&gt;&lt;p&gt;The &lt;code&gt;gtb&lt;/code&gt; binary is the generator. &lt;code&gt;gtb generate project&lt;/code&gt; scaffolds a new tool, &lt;code&gt;gtb generate command&lt;/code&gt; adds a command, and &lt;code&gt;add-flag&lt;/code&gt;, &lt;code&gt;regenerate&lt;/code&gt;, &lt;code&gt;remove&lt;/code&gt;, &lt;code&gt;enable&lt;/code&gt; and &lt;code&gt;disable&lt;/code&gt; keep working on the tool after day one. &lt;code&gt;gtb enable signing&lt;/code&gt;, for example, wires in signed self-update with the verification turned on.&lt;/p&gt;&#10;&lt;h3 id="long-running-too"&gt;Long-running, too&#10;&lt;/h3&gt;&lt;p&gt;A tool that needs to stay up gets the same supervisor the framework&amp;rsquo;s service commands use, &lt;a class="link" href="https://controls.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;controls&lt;/a&gt;: concurrent startup, health and readiness probes, shutdown in reverse order of registration, and restarts when something falls over. That&amp;rsquo;s what sits under a studio served from the CLI, or a bot that&amp;rsquo;s meant to run for weeks, and the config, update and signing story is the same one the short-lived commands get.&lt;/p&gt;&#10;&lt;h3 id="made-of-modules-not-a-monolith"&gt;Made of modules, not a monolith&#10;&lt;/h3&gt;&lt;p&gt;Most of what go-tool-base does lives in separate toolkit modules it requires: &lt;a class="link" href="https://config.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;config&lt;/a&gt;, &lt;a class="link" href="https://errors.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;errors&lt;/a&gt;, &lt;a class="link" href="https://transport.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;transport&lt;/a&gt;, &lt;a class="link" href="https://observability.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;observability&lt;/a&gt;, &lt;a class="link" href="https://forge.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;forge&lt;/a&gt; for releases and self-update, &lt;a class="link" href="https://signing.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;signing&lt;/a&gt;, &lt;a class="link" href="https://mcp.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;MCP&lt;/a&gt; and &lt;a class="link" href="https://chat.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;chat&lt;/a&gt;. go-tool-base is assembled from 45 of them, and the &lt;a class="link" href="https://go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;toolkit&lt;/a&gt; they come from now holds 93 public modules, nearly all of them split out since the extraction playbook was written in mid-July 2026. You can use any of them without go-tool-base, and go-tool-base is what makes them agree with each other.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Split into modules.&lt;/strong&gt; The framework was broken out into modules one package at a time, following &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0119-go-module-extraction-playbook" target="_blank" rel="noopener"&#10; &gt;an extraction playbook (spec 0119)&lt;/a&gt;, and the generator itself became &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0194-the-gtb-cli-as-a-nested-module-and-adapters-chosen-per-tool" target="_blank" rel="noopener"&#10; &gt;a nested module of its own (spec 0194)&lt;/a&gt;. The payoff was measurable: a minimal binary went from 81.3 MB to 34.3 MB, 58% smaller, once the adapters stopped coming along for the ride. &lt;em&gt;What it cost:&lt;/em&gt; shared test harnesses get copied into each module, and the generator&amp;rsquo;s own packages are importable but not supported.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Off Viper.&lt;/strong&gt; Config moved from a Viper wrapper to go/config&amp;rsquo;s store (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0136-config-v0.3-migration" target="_blank" rel="noopener"&#10; &gt;spec 0136&lt;/a&gt;). What decided it was coherence: reading from a frozen snapshot means a set of related reads never mixes values from before and after a reload, and config&amp;rsquo;s own test holds that at zero mismatches. A forwarding adapter that would have kept the old API was turned down because it brought the mismatch straight back. &lt;em&gt;What it cost:&lt;/em&gt; roughly 350 call sites and 595 lines of documentation, rewritten.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;An errors package of my own.&lt;/strong&gt; I&amp;rsquo;d originally chosen cockroachdb/errors (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0001-cockroachdb-errors-migration" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;), and later reversed my own decision: the estate used ten of its functions, and paid 46 of go/forge&amp;rsquo;s 376 transitive packages for them. &lt;a class="link" href="https://gitlab.com/phpboyscout/go/errors/-/wikis/specs/0001-an-errors-package-we-own" target="_blank" rel="noopener"&#10; &gt;go/errors&lt;/a&gt; replaced it, with hints for the user built in (the reason an alternative without them was passed over). &lt;em&gt;What it cost:&lt;/em&gt; one change touching 116 files and 50 sentinel errors.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Signed self-update.&lt;/strong&gt; An update is verified against a signature before it&amp;rsquo;s installed (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0056-remote-update-checksum-verification" target="_blank" rel="noopener"&#10; &gt;spec 0056&lt;/a&gt;), using GPG with keys published over WKD, chosen over cosign so verification still works offline and behind a firewall. &lt;em&gt;What it cost:&lt;/em&gt; a compromise of both the forge and the key directory at once wouldn&amp;rsquo;t be detected until a transparency log lands, and that&amp;rsquo;s still a draft.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;Seven tools are built on it today, all on the same release: keryx, krites, &lt;a class="link" href="https://sigillum.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;sigillum&lt;/a&gt;, skillup, phpbotscout, &lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt; and &lt;a class="link" href="https://scoutdm.com/" target="_blank" rel="noopener"&#10; &gt;Scout&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;66 releases since v0.1.0 in May 2026, and the version in the header above is the current one. A tool&amp;rsquo;s release merge request runs cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-core-currency/" target="_blank" rel="noopener"&#10; &gt;go-core-currency&lt;/a&gt; check, which flags a release still built on an older go-tool-base. All seven, and go-tool-base itself, build and publish their binaries through cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/goreleaser/" target="_blank" rel="noopener"&#10; &gt;goreleaser component&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;290 behaviour scenarios across 52 feature files, and 2,954 test and fuzz functions, with a 90% coverage threshold on the pipeline.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you&amp;rsquo;re building a Go application that real people will install and keep updated, whether that&amp;rsquo;s a command-line tool, something that runs for weeks, or a local web UI served from the same binary, and you&amp;rsquo;d rather not write config layering, signed self-update, graceful shutdown, a doctor command and an MCP server for the fourth time. Release builds cover Linux, macOS and Windows on amd64 and arm64.&lt;/p&gt;&#10;&lt;p&gt;It isn&amp;rsquo;t a web framework, though. It won&amp;rsquo;t route your requests or render your pages, and it won&amp;rsquo;t scaffold a microservice for you: what a long-running process gets from it is the lifecycle around the work, not the work. And it&amp;rsquo;s overkill if all you need is a command router. It&amp;rsquo;s also pre-1.0: breaking changes still ship as minor releases and the &lt;a class="link" href="https://gtb.phpboyscout.uk/reference/api-stability/" target="_blank" rel="noopener"&#10; &gt;stability tiers&lt;/a&gt; aren&amp;rsquo;t in force yet, so pin the version.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0203-the-static-release-channel" target="_blank" rel="noopener"&#10; &gt;static release channel (spec 0203)&lt;/a&gt; has shipped. A tool can find and fetch its own releases from a plain HTTPS location, with no forge in either step, and an updater that needs nothing but HTTPS and its embedded keys is one that can repair a broken install. keryx is the next tool to move onto it. Behind that, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0104-update-channels-rollback" target="_blank" rel="noopener"&#10; &gt;update channels and rollback (spec 0104)&lt;/a&gt; are drafted, and need another look now that the channel lists every version (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/work_items/117" target="_blank" rel="noopener"&#10; &gt;go-tool-base#117&lt;/a&gt;).&lt;/p&gt;&#10;</description></item><item><title>keryx</title><link>https://phpboyscout.uk/showcase/keryx/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/keryx/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;One person writes a blog post. Then it wants a cover, perhaps a narrated vertical reel with music of its own, and a differently shaped post for each social network, each one sent on the right day. Doing that by hand for every article is a second job.&lt;/p&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://keryx.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;keryx&lt;/a&gt; turns it into commands, and the blog&amp;rsquo;s pipeline runs them unattended. The name is ancient Greek for a herald, which is precisely the job. It has made this blog&amp;rsquo;s reels since June 2026, and has sent its social posts on schedule, across six platforms, since 20 September 2026.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="everything-is-a-file-in-your-repository"&gt;Everything is a file in your repository&#10;&lt;/h3&gt;&lt;p&gt;It&amp;rsquo;s one Go binary that keeps no state of its own. Reels, themes, schedules and the record of what went where all live in the project&amp;rsquo;s own git repository, so a cover, a storyboard or an approval is something you can review in a diff.&lt;/p&gt;&#10;&lt;h3 id="generate-several-pick-one"&gt;Generate several, pick one&#10;&lt;/h3&gt;&lt;p&gt;Every step that costs money generates a few candidates and lets you choose: covers in 16:9, and reels as 9:16 text cards over generated images, with narration in a cloned voice and a music bed to match. A local web studio covers writing, previewing, approving and publishing, and making a whole reel in one pass asks before it spends anything.&lt;/p&gt;&#10;&lt;h3 id="approved-then-sent"&gt;Approved, then sent&#10;&lt;/h3&gt;&lt;p&gt;Social copy is written per platform, because a Bluesky post isn&amp;rsquo;t a LinkedIn post. It can send to nine networks, and the blog uses six: Bluesky, Instagram, Facebook, LinkedIn, Threads and Mastodon. Nothing goes out without a human approval and a due hour, and an hourly job sends whatever has come due.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Render in memory.&lt;/strong&gt; Reels render through &lt;a class="link" href="https://phpboyscout.uk/showcase/media-in-pure-go/" &gt;afmpeg&lt;/a&gt;, so any project can render without FFmpeg installed or a local checkout (&lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/wikis/specs/0021-in-memory-render" target="_blank" rel="noopener"&#10; &gt;spec 0021&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; the portable engine took about eight and a half minutes on a short three-card reel where native FFmpeg takes seconds.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;No themes in the binary.&lt;/strong&gt; keryx ships with no themes at all (&lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/wikis/specs/0046-theme-scopes" target="_blank" rel="noopener"&#10; &gt;spec 0046&lt;/a&gt;), on the principle that the seed is code and themes are content, so a change of taste never needs a release. &lt;em&gt;What it cost:&lt;/em&gt; a fresh install can&amp;rsquo;t generate anything until you bring a theme of your own.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A studio that locks itself.&lt;/strong&gt; On localhost the studio is open; bound to any other address it mints a one-run token as a strict cookie, and refuses to start if it can&amp;rsquo;t build that gate (&lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/wikis/specs/0018-studio-exposure-auth" target="_blank" rel="noopener"&#10; &gt;spec 0018&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a cookie verifier had to go into go-tool-base first.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Mastodon instead of X.&lt;/strong&gt; Bluesky got a small hand-written client, and Mastodon took X&amp;rsquo;s place in the default set because X&amp;rsquo;s API now charges per use (&lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/wikis/specs/0060-text-and-image-announcements" target="_blank" rel="noopener"&#10; &gt;spec 0060&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; an X adapter that&amp;rsquo;s built and tested and sits unused.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;This blog&amp;rsquo;s social posts have gone out through it since 20 September 2026, and its reels since June 2026.&lt;/li&gt;&#10;&lt;li&gt;31 releases since June 2026, built on &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt;, writing copy through &lt;a class="link" href="https://phpboyscout.uk/showcase/chat/" &gt;chat&lt;/a&gt;, and rendering with &lt;a class="link" href="https://phpboyscout.uk/showcase/media-in-pure-go/" &gt;afmpeg&lt;/a&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you publish regularly and want covers, reels and social posts made and scheduled from the same repository as the writing, with you approving every one.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s built to swap providers, but for now each capability ships with one: Gemini for images, ElevenLabs for voice and music, five options for the chat that writes the copy. It isn&amp;rsquo;t a hosted service, there&amp;rsquo;s no video generation, reels have a fixed shape (no editing timeline, nothing long-form), card text is Latin script only, and a LinkedIn sign-in lasts about sixty days before you renew it by hand. The &lt;a class="link" href="https://keryx.phpboyscout.uk/explanation/limitations/" target="_blank" rel="noopener"&#10; &gt;limitations page&lt;/a&gt; has the rest.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Grounding the social copy more firmly in the article it&amp;rsquo;s about, and a static release channel for its own updates, are next.&lt;/p&gt;&#10;</description></item><item><title>krites</title><link>https://phpboyscout.uk/showcase/krites/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/krites/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;A full wedding day comes back as three or four thousand frames, and every one of them has to be judged before any editing can start. By hand, that can take a photographer weeks. krites was built for one photographer in particular, my wife, who had four thousand frames and a weekend to get through them (&lt;a class="link" href="https://phpboyscout.uk/i-built-my-wife-a-judge/" &gt;I built my wife a judge&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;And a wedding&amp;rsquo;s photos are sensitive, so the whole thing runs on her own laptop: no subscription, it works with no signal, and nothing leaves the machine unless she switches a cloud option on for herself.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="a-first-pass-with-reasons"&gt;A first pass, with reasons&#10;&lt;/h3&gt;&lt;p&gt;Point it at a folder and it marks every frame keep, maybe or reject, each with a reason in plain words. Blur, exposure and near-duplicate bursts are judged with plain arithmetic (&lt;a class="link" href="https://phpboyscout.uk/no-ai-in-my-photo-culler/" &gt;There&amp;rsquo;s no AI in my photo culler&lt;/a&gt;), and eyes and expressions with small models that run locally.&lt;/p&gt;&#10;&lt;h3 id="review-in-a-studio-on-your-own-machine"&gt;Review in a studio on your own machine&#10;&lt;/h3&gt;&lt;p&gt;The review happens in a browser studio that only ever runs on your laptop. The originals are never written to: every decision is kept as a record beside the shoot, only an export writes new images, and sidecar files carry the cull straight into Lightroom (&lt;a class="link" href="https://phpboyscout.uk/ten-lines-of-xml-so-a-cull-shows-up-in-lightroom/" &gt;ten lines of XML&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Licence over benchmark.&lt;/strong&gt; The most accurate expression models were trained on a dataset whose terms forbid commercial use of anything derived from it, so krites uses MediaPipe&amp;rsquo;s face models instead, converted to run locally (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/wikis/specs/0029-mediapipe-expression" target="_blank" rel="noopener"&#10; &gt;spec 0029&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; about a fifth of faces got no expression reading at first, tuned down to 15%, which is acceptable because a face with no reading never causes a wrong reject.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A better detector.&lt;/strong&gt; YuNet replaced the old face detector as the default rather than outright (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/wikis/specs/0033-pluggable-face-detector" target="_blank" rel="noopener"&#10; &gt;spec 0033&lt;/a&gt;). On a real wedding it found 718 faces against 636, 13% more, with no change in rejects. &lt;em&gt;What it cost:&lt;/em&gt; switching detector means re-culling the shoot.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Opt-in removal.&lt;/strong&gt; Removal runs locally with LaMa, or MI-GAN as an alternative, turning down diffusion models and anything whose own licence forbids commercial use (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/wikis/specs/0008-object-removal" target="_blank" rel="noopener"&#10; &gt;spec 0008&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; both models were trained on a dataset with non-commercial terms, so removal stays switched off until the user explicitly accepts that.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;No learning yet.&lt;/strong&gt; Learning the photographer&amp;rsquo;s own taste was parked until there&amp;rsquo;s real use to learn from (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/wikis/specs/0036-learning-her-taste" target="_blank" rel="noopener"&#10; &gt;spec 0036&lt;/a&gt;), because with no human corrections yet, picking a learning goal would be a guess dressed as a design. &lt;em&gt;What it cost:&lt;/em&gt; it doesn&amp;rsquo;t learn yet, and most frames still land on &amp;ldquo;maybe&amp;rdquo;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;It was built after a real fourteen-hour wedding of more than four thousand frames, and its detector change was proved by re-culling a real shoot.&lt;/li&gt;&#10;&lt;li&gt;Blink detection from the shape of the eye catches 12 of 14 real blinks, against 4 of 14 for the trained model it was compared with.&lt;/li&gt;&#10;&lt;li&gt;19 releases since June 2026, and its models and runtime come through &lt;a class="link" href="https://phpboyscout.uk/showcase/secure-artefact-delivery/" &gt;Secure artefact delivery&lt;/a&gt;, signed and checked like everything else.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you shoot events, come home with thousands of frames, and want a first pass you can understand and overrule, without your clients&amp;rsquo; photos going anywhere.&lt;/p&gt;&#10;&lt;p&gt;It isn&amp;rsquo;t a catalogue or a RAW developer (a RAW frame exports as a JPEG), it works on one shoot at a time, there&amp;rsquo;s no Windows build, and the Mac download is Apple Silicon only. It doesn&amp;rsquo;t score aesthetics or learn from you yet, ingest skips subfolders and some newer RAW formats, and the optional AI review sends the full image to a third party. The &lt;a class="link" href="https://krites.phpboyscout.uk/explanation/limitations/" target="_blank" rel="noopener"&#10; &gt;limitations page&lt;/a&gt; has the rest.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Learning from real corrections once there are enough of them, finishing the expression pipeline&amp;rsquo;s tuning, and replacing the removal models with ones whose training data is clean, which is still an open research question.&lt;/p&gt;&#10;</description></item><item><title>mcp</title><link>https://phpboyscout.uk/showcase/mcp/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/mcp/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;The obvious way to put a command-line tool behind MCP is one tool per command, and every one of those tools brings its schema into the agent&amp;rsquo;s context before the conversation even starts. The feasibility spike measured how that grows: about 16.7 KB for ten commands, 167 KB for a hundred and 1.67 MB for a thousand, against a fixed few hundred bytes for a small three-tool front door (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/mcp/-/wikis/reports/2026-09-10-mcp-feasibility-spike" target="_blank" rel="noopener"&#10; &gt;the spike&lt;/a&gt;). On a real build, go-tool-base&amp;rsquo;s whole listing through that front door is about 1.5 KB.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="three-tools-in-front-of-everything"&gt;Three tools in front of everything&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://mcp.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/mcp&lt;/a&gt; puts one registry behind three discovery tools: search, details and call. An operation&amp;rsquo;s schema only reaches the conversation when an agent actually asks for it. A direct mode, publishing one tool per operation, is there when a client really wants it.&lt;/p&gt;&#10;&lt;h3 id="commands-http-and-grpc-behind-one-registry"&gt;Commands, HTTP and gRPC behind one registry&#10;&lt;/h3&gt;&lt;p&gt;Command-line commands run as supervised subprocesses of the same binary, an HTTP service mounts a single handler, and gRPC methods bind in-process through the service&amp;rsquo;s own interceptors. Being listed in the catalogue isn&amp;rsquo;t permission: the policy is asked again at every step, and a denied operation answers exactly like an unknown one.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Compact by default.&lt;/strong&gt; The three-tool front door is the default and direct publishing has to be chosen (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/mcp/-/wikis/specs/0001-progressive-mcp" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;), rather than guessing the mode from how a client behaves. &lt;em&gt;What it cost:&lt;/em&gt; a client&amp;rsquo;s approval prompt only ever sees the call tool, which has to carry the most cautious labels.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Commands run out of process.&lt;/strong&gt; A command is run as a fresh subprocess of the tool, never in-process, because a command tree is shared mutable state. &lt;em&gt;What it cost:&lt;/em&gt; one call at a time (a second gets an immediate, retryable busy), a five-minute limit, a megabyte of output, and stricter checking of custom flags.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Permission at every step.&lt;/strong&gt; The catalogue and execution are shared by every publishing mode, and the policy is checked on each request, not just once at listing.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;gRPC in-process.&lt;/strong&gt; gRPC calls go through the service&amp;rsquo;s own interceptors in-process, rather than dialling the service&amp;rsquo;s own listener, which would need a service-to-itself token (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/mcp/-/wikis/specs/0002-grpc-unary-bindings" target="_blank" rel="noopener"&#10; &gt;spec 0002&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; single request and response only; streaming methods are refused when they&amp;rsquo;re registered.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; serves its MCP endpoint through it, so every tool generated on go-tool-base with MCP switched on gets the front door for free.&lt;/li&gt;&#10;&lt;li&gt;It&amp;rsquo;s tested against three versions of the official Go MCP SDK.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you want AI agents to be able to drive your Go tool&amp;rsquo;s commands or services, and your tool has enough of them that publishing every schema up front would crowd the conversation.&lt;/p&gt;&#10;&lt;p&gt;It doesn&amp;rsquo;t authenticate callers, and it doesn&amp;rsquo;t sandbox commands beyond a process group, so one mode of it isn&amp;rsquo;t suitable over HTTP at all. The publishing mode is fixed when the server is built, and cleanup on macOS and Windows is designed and compiled but not yet proven in CI.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;keryx&amp;rsquo;s studio as a second user, native macOS and Windows acceptance, gRPC streaming, and a bridge into &lt;a class="link" href="https://phpboyscout.uk/showcase/chat/" &gt;chat&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>Media in pure Go</title><link>https://phpboyscout.uk/showcase/media-in-pure-go/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/media-in-pure-go/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;keryx makes its promo reels with FFmpeg, and it used to do that by running the &lt;code&gt;ffmpeg&lt;/code&gt; program, which needs real files on a real disk. So a project held only in memory couldn&amp;rsquo;t be rendered at all. The usual ways round that each had a catch: pure-Go bindings weren&amp;rsquo;t mature, CGO bindings break static cross-compiles, and the existing WebAssembly builds of FFmpeg were missing the crossfade filter and AAC audio, had nowhere to put files, and were pinned to FFmpeg 5.1, which no longer gets security fixes.&lt;/p&gt;&#10;&lt;p&gt;So the goal was FFmpeg in a Go program with no CGO, nothing installed on the host and no temp files. It&amp;rsquo;s two projects. &lt;a class="link" href="https://ffmpeg-wasi.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;ffmpeg-wasi&lt;/a&gt; builds the engine, and &lt;a class="link" href="https://afmpeg.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;afmpeg&lt;/a&gt; runs it from Go.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="ffmpeg-wasi-current-ffmpeg-in-a-sandbox"&gt;ffmpeg-wasi: current FFmpeg in a sandbox&#10;&lt;/h3&gt;&lt;p&gt;ffmpeg-wasi compiles FFmpeg&amp;rsquo;s libraries to WebAssembly and drives them with an engine of its own, which takes a structured job (inputs, filters, outputs) instead of an &lt;code&gt;ffmpeg&lt;/code&gt; command line. What comes out is a single &lt;code&gt;.wasm&lt;/code&gt; file that runs on any machine and any processor architecture with a WebAssembly runtime, and it tracks current FFmpeg (n9.0.1 today). That matters for a library whose whole job is reading media from strangers.&lt;/p&gt;&#10;&lt;p&gt;The sandbox is the point. The engine sees only the files it&amp;rsquo;s handed, and nothing else on the machine. Its memory is capped, so a crafted file that claims enormous frame sizes and asks for gigabytes gets an allocation failure inside the sandbox, and the job fails. Without the cap that allocation lands in the host process, the kernel kills it, and every other request in flight goes with it.&lt;/p&gt;&#10;&lt;h3 id="afmpeg-running-it-from-go"&gt;afmpeg: running it from Go&#10;&lt;/h3&gt;&lt;p&gt;afmpeg runs that engine inside the Go program under &lt;a class="link" href="https://wazero.io/" target="_blank" rel="noopener"&#10; &gt;wazero&lt;/a&gt;, a WebAssembly runtime written in Go. Every file the engine opens is answered from a filesystem the caller hands it, an in-memory one or anything else, and each job gets fresh in-memory scratch space, so FFmpeg believes it has a disk and never touches one (&lt;a class="link" href="https://phpboyscout.uk/ffmpeg-thinks-it-has-a-disk/" &gt;FFmpeg thinks it has a disk&lt;/a&gt;). The limits are on before you ask: 512 MB of memory, an hour per job, and one job at a time per runtime.&lt;/p&gt;&#10;&lt;h3 id="the-native-driver-same-engine-native-speed"&gt;The native driver: same engine, native speed&#10;&lt;/h3&gt;&lt;p&gt;The sandbox has a price. WebAssembly here means one thread and no SIMD (the processor instructions that do many sums at once), and video encoders lean on both. So ffmpeg-wasi also compiles the same engine as a native Linux program with real threads and SIMD, and afmpeg runs it as a separate process, still without CGO. The caller&amp;rsquo;s filesystem is served to it over a local socket, with reads, writes and seeks each sent as a message, so even the way an MP4 writer jumps back to patch its header goes through the caller&amp;rsquo;s filesystem. Nothing touches the host disk on this path either.&lt;/p&gt;&#10;&lt;p&gt;The custom driver is what makes this work. Against the sandboxed engine it encodes about 50 times faster with openh264 and about 170 times faster with libx264. Against the host&amp;rsquo;s own &lt;code&gt;ffmpeg&lt;/code&gt; program, on the same jobs, it took between a fifth of the time and slightly under the same time (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/wikis/reports/2026-08-native-vs-wasm-speed" target="_blank" rel="noopener"&#10; &gt;the speed report&lt;/a&gt;). That comparison isn&amp;rsquo;t exact: the host program was an older FFmpeg, and it used temp files where the driver stayed in memory. The fair reading is that the socket bridge and the no-disk guarantee cost nothing measurable, and the driver runs at native speed. It also carries the heavy HEVC and AV1 encoders that are impractical in WebAssembly.&lt;/p&gt;&#10;&lt;p&gt;The driver isn&amp;rsquo;t a sandbox, so it&amp;rsquo;s fenced in instead. On Linux the kernel&amp;rsquo;s Landlock file restrictions limit what it can open, and any access it can&amp;rsquo;t account for is fatal rather than silent.&lt;/p&gt;&#10;&lt;h3 id="signed-from-source-to-runtime"&gt;Signed from source to runtime&#10;&lt;/h3&gt;&lt;p&gt;Every input to the build is pinned: the toolchain image, the FFmpeg tag, and each codec library by tag, commit or checksum. Downloaded source has to match a hard-coded checksum, and a mirror that disagrees is rejected. Each release publishes a checksum file covering every &lt;code&gt;.wasm&lt;/code&gt; module, every native driver and the build record, and one OpenPGP signature over that file certifies the lot.&lt;/p&gt;&#10;&lt;p&gt;The signing key lives in AWS KMS and never leaves it, and only this project&amp;rsquo;s release-tag pipeline can use it, by proving its identity to AWS rather than holding a credential (&lt;a class="link" href="https://phpboyscout.uk/showcase/signing-and-trust/" &gt;signing and trust&lt;/a&gt; covers how). afmpeg carries the public key inside its own code and cross-checks it against a copy published on phpboyscout.uk, which GitLab doesn&amp;rsquo;t control. It checks the signature and the checksum before an engine or a driver is written to disk or run, so a program using afmpeg never runs an engine it hasn&amp;rsquo;t verified.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Libraries, not the program.&lt;/strong&gt; Linking FFmpeg&amp;rsquo;s libraries into an engine of its own beat compiling the &lt;code&gt;ffmpeg&lt;/code&gt; command-line program, which would have meant either an end-of-life FFmpeg or a multithreaded version the WebAssembly runtime can&amp;rsquo;t run (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/wikis/specs/0007-libav-direct-engine" target="_blank" rel="noopener"&#10; &gt;spec 0007&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a C engine and a versioned job vocabulary to own, and ffmpeg command lines aren&amp;rsquo;t accepted.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Licences kept apart.&lt;/strong&gt; The Go package stays MIT, and the engine is a separate, signature-checked download that&amp;rsquo;s never embedded, so FFmpeg&amp;rsquo;s GPL or LGPL terms land only on whoever chooses and bundles an engine (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/wikis/specs/0001-afmpeg" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; you have to pick and fetch an engine, and without one the library won&amp;rsquo;t start.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Native, but no CGO.&lt;/strong&gt; The fast path is the same engine in a separate process rather than FFmpeg linked in through CGO, which would taint the MIT licence and lose the static cross-compile (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/wikis/specs/0028-native-subprocess-backend" target="_blank" rel="noopener"&#10; &gt;spec 0028&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a socket protocol to own, the memory cap and engine progress don&amp;rsquo;t apply to the driver, and it&amp;rsquo;s published for Linux on amd64 only.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Fence the native driver.&lt;/strong&gt; After two independent architecture reviews threw out two earlier designs, the native driver&amp;rsquo;s file access is restricted by default and any access it can&amp;rsquo;t account for is fatal (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/wikis/specs/0043-native-driver-filesystem-boundary" target="_blank" rel="noopener"&#10; &gt;spec 0043&lt;/a&gt;). &lt;a class="link" href="https://phpboyscout.uk/the-escape-hatch-that-turned-out-to-be-french-doors/" &gt;The escape hatch that turned out to be french doors&lt;/a&gt; is how that came about.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;keryx renders its reels with it, fetching the engine as a signed download.&lt;/li&gt;&#10;&lt;li&gt;22 releases of afmpeg and 20 of the engine, which is on FFmpeg n9.0.1. Every engine release, native drivers included, is covered by one signature.&lt;/li&gt;&#10;&lt;li&gt;The speed gap was re-measured across a major FFmpeg version and didn&amp;rsquo;t move, because it comes from WebAssembly&amp;rsquo;s single thread and missing SIMD rather than from FFmpeg.&lt;/li&gt;&#10;&lt;li&gt;The lean engine is about 5 to 6 MB to download.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if a Go program needs to transcode, mix or cut media without asking anyone to install FFmpeg, and without CGO or temp files. Reach for the sandboxed engine when the media comes from people you don&amp;rsquo;t trust or the program has to run anywhere, and for the native driver when speed matters and you&amp;rsquo;re on Linux.&lt;/p&gt;&#10;&lt;p&gt;The sandboxed engine is slow on encodes, roughly 50 to 170 times slower than the native driver, which is fine for short clips and a reason to switch for anything heavy. Decoding and filtering cost far less. There&amp;rsquo;s no network streaming, GPU encoding or capture devices, HEVC and AV1 encoding exist only in the native driver, jobs run one at a time per runtime, and the native driver is Linux amd64 only. The &lt;a class="link" href="https://afmpeg.phpboyscout.uk/reference/limitations/" target="_blank" rel="noopener"&#10; &gt;limitations page&lt;/a&gt; has the rest.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Better audio and video sync is in review. Multithreading inside the WebAssembly engine, progress reporting from the native driver and a command-line tool for people who&amp;rsquo;d rather not write Go are all drafted and parked, each until something needs it.&lt;/p&gt;&#10;</description></item><item><title>messaging</title><link>https://phpboyscout.uk/showcase/messaging/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/messaging/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;A service that talks to another over a Go channel has to be rewritten the day the other end moves out of the process. An earlier attempt at a message bus here was designed directly against NATS, and it showed: a subscriber that panicked took the whole process down with it.&lt;/p&gt;&#10;&lt;p&gt;The deeper lesson was about abstraction. One with a single implementation is shaped entirely by that implementation, and an in-memory backend that copies NATS proves nothing about anything that isn&amp;rsquo;t NATS (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/messaging/-/wikis/specs/0001-a-message-bus-for-the-estate" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;). So the contract was treated as provisional until something genuinely different arrived, and when SQS, AMQP 1.0 and RabbitMQ were specified, they forced nine changes to the core contract before any of them could be built.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="services-talk-to-the-bus-never-the-transport"&gt;Services talk to the bus, never the transport&#10;&lt;/h3&gt;&lt;p&gt;A service imports the bus and CloudEvents, and nothing else, so moving from in-memory to NATS or a cloud queue is a choice of backend, not a rewrite. Under the five modules sit seven backends: in-memory, core NATS, JetStream, SQS FIFO, SQS Standard, RabbitMQ and AMQP 1.0. As well as publish and subscribe, a service can ask a question and wait for one answer, and every request is counted by how it ended. &lt;a class="link" href="https://nats.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/nats&lt;/a&gt; embeds a NATS server in the process, for tests and single-binary deployments.&lt;/p&gt;&#10;&lt;h3 id="bounded-counted-and-declared"&gt;Bounded, counted, and declared&#10;&lt;/h3&gt;&lt;p&gt;Every subscription chooses how hard delivery tries. The default is at most once: one attempt, and a failure is counted rather than retried. At least once retries until the handler acknowledges, up to a limit, under a name the broker recognises after a restart. Asking for it on a backend that can&amp;rsquo;t survive a restart is refused unless the subscription says that&amp;rsquo;s fine. Every subscription has a size limit, and every message dropped at that limit is counted against the subscription it was meant for. Anything a backend can do beyond the basics it has to declare, and one shared conformance suite holds every backend to exactly what it declares.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;The unit is a CloudEvent.&lt;/strong&gt; Messages are &lt;a class="link" href="https://cloudevents.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;CloudEvents&lt;/a&gt;, not raw bytes or any Go value. &lt;em&gt;What it cost:&lt;/em&gt; anything that can&amp;rsquo;t be serialised can&amp;rsquo;t travel. Audio and video can, as bytes in an event&amp;rsquo;s data, but not efficiently. Each frame carries its own envelope and is copied once per subscriber, frames dropped at a full queue can&amp;rsquo;t be asked for again, and order holds only when one handler takes one message at a time (&lt;a class="link" href="https://messaging.go.phpboyscout.uk/explanation/limitations/" target="_blank" rel="noopener"&#10; &gt;the limitations&lt;/a&gt;). A direct stream does that job better.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Bounded queues.&lt;/strong&gt; Every queue is bounded and every drop counted, but what happens at a full queue is each backend&amp;rsquo;s to offer, rather than a queue bolted in front of every backend to make the options mean the same thing everywhere. &lt;em&gt;What it cost:&lt;/em&gt; swapping backend can change what happens when a queue is full; core NATS can only refuse the newest message.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Generality has to be proven.&lt;/strong&gt; Three backends unlike NATS were specified before NATS itself was built, with a shared conformance suite to keep them honest. &lt;em&gt;What it cost:&lt;/em&gt; the nine contract changes, an SQS race suite that takes about eleven minutes, and a test image pinned to the last version that runs without an auth token.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Declare the edge case.&lt;/strong&gt; JetStream declares that a server restart can use up a delivery attempt nobody saw (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/messaging/-/wikis/specs/0012-a-restart-can-spend-the-last-attempt" target="_blank" rel="noopener"&#10; &gt;spec 0012&lt;/a&gt;), instead of hiding it or trying to rebuild the count from server advisories. &lt;em&gt;What it cost:&lt;/em&gt; the count of exhausted messages can come up one short, measured at 11 runs in 250.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;phpbotscout and comms-discord run on it, and so does &lt;a class="link" href="https://scoutdm.com/" target="_blank" rel="noopener"&#10; &gt;Scout&lt;/a&gt;, its first user of request and reply.&lt;/li&gt;&#10;&lt;li&gt;The conformance suite runs five deliberately lying backends and requires every one to fail, after an audit found a lying backend could pass ten green cases. SQS Standard passes all 31 of its rows.&lt;/li&gt;&#10;&lt;li&gt;22 releases across the bus and its four backends since September 2026, under &lt;a class="link" href="https://phpboyscout.uk/showcase/controls/" &gt;controls&lt;/a&gt; for its lifecycle. A backend&amp;rsquo;s release merge request runs cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-core-currency/" target="_blank" rel="noopener"&#10; &gt;go-core-currency&lt;/a&gt; check against the bus&amp;rsquo;s latest release.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if your Go services need to talk asynchronously and you&amp;rsquo;d like to start in memory and move to NATS or a cloud queue later without rewriting them.&lt;/p&gt;&#10;&lt;p&gt;Nothing promises exactly once, so a handler subscribed at least once has to tolerate duplicates. A subscriber sees its messages in order only while it reads one at a time. Give it more readers and order goes, and nothing orders messages across subscribers. A message a handler turns down is redelivered straight away, with no backoff yet, so a handler waiting for a dependency to recover has to do its own waiting before it says no. Nothing routes traffic around a struggling consumer, and it isn&amp;rsquo;t a universal broker abstraction: anything past the two portable delivery shapes is a capability a backend declares. go/nats runs an embedded server in-process only, because it has no TLS or authentication for a listener yet, though a client connects to a cluster someone else runs just fine. &lt;a class="link" href="https://schema.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/schema&lt;/a&gt;, the schema registry, is at its first release and says itself it isn&amp;rsquo;t obviously needed yet.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Backoff on redelivery, capabilities that depend on the connection, a deduplication window for embedded NATS, and keeping the message envelope swappable.&lt;/p&gt;&#10;</description></item><item><title>phpbotscout</title><link>https://phpboyscout.uk/showcase/phpbotscout/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/phpbotscout/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Support questions arrive on Discord, get answered from memory, scroll away, and get asked again a week later. The problems worth tracking rarely become GitLab issues, and quite often the answer was already sitting in the docs.&lt;/p&gt;&#10;&lt;p&gt;phpbotscout answers what the docs already cover, with citations, and treats every question it can&amp;rsquo;t answer as &amp;ldquo;a documentation gap with a timestamp on it&amp;rdquo;. Picking its model was a costed decision: on 36 calibration questions run through the real answer path, the small model matched the big one closely enough that the difference came down to about £5 a month at fifty questions a day, against £38 (&lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/wikis/specs/0009-provider-benchmark-decision" target="_blank" rel="noopener"&#10; &gt;spec 0009&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="answer-only-from-what-it-was-shown"&gt;Answer only from what it was shown&#10;&lt;/h3&gt;&lt;p&gt;It reads the public channels, searches an index built from the estate&amp;rsquo;s public docs and code, and hands the model eight passages per question. The model answers only from those passages, and an answer that cites nothing, or cites something it was never given, is turned into a decline instead (&lt;a class="link" href="https://phpbotscout.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;how answering works&lt;/a&gt;). &lt;a class="link" href="https://phpboyscout.uk/the-most-tempting-corpus-in-any-repository/" &gt;The most tempting corpus in any repository&lt;/a&gt; is about what went into that index and what didn&amp;rsquo;t.&lt;/p&gt;&#10;&lt;h3 id="discord-behind-a-contract"&gt;Discord behind a contract&#10;&lt;/h3&gt;&lt;p&gt;Discord sits behind &lt;a class="link" href="https://comms.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/comms&lt;/a&gt;, a contract for chat platforms with no vendor SDK in its core, and go/comms-discord is the provider that speaks Discord.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;The small model, for now.&lt;/strong&gt; Haiku 4.5 scored nearly as well as Opus and was the fastest of the three. Sonnet was turned down at four times the cost for identical scores and the worst slow-case latency, and Opus at 7.6 times the cost, partly because it writes so much more (&lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/wikis/specs/0009-provider-benchmark-decision" target="_blank" rel="noopener"&#10; &gt;spec 0009&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; the choice is provisional, because citation accuracy, which the spec calls the worst failure there is, wasn&amp;rsquo;t measured.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A library that resumes.&lt;/strong&gt; disgo was picked because it was the only one that resumes on the reconnect address Discord gives it, proved live by replaying messages posted during a twenty-second outage (&lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/wikis/specs/0004-discord-library-decision" target="_blank" rel="noopener"&#10; &gt;spec 0004&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; the heaviest dependency footprint of the three, a slower connect, and a library that&amp;rsquo;s still below 1.0.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Quiet until a server opts in.&lt;/strong&gt; By default it composes answers without posting them, and a server only gets posts if its own setting says so. A server that hasn&amp;rsquo;t opted in gets a bot built read-only, with no way to post at all (&lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/wikis/specs/0025-posting-in-a-server-that-opts-in" target="_blank" rel="noopener"&#10; &gt;spec 0025&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; that setting had to be restructured per server.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Logs carry no message text.&lt;/strong&gt; No message text or author ends up in a log line, so retention stays where the policy enforces it (&lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/wikis/specs/0028-logs-carry-no-message-text" target="_blank" rel="noopener"&#10; &gt;spec 0028&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; the per-message trace is gone.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;It&amp;rsquo;s been running in shadow mode since September 2026, reading every public channel and composing answers, and posting only where a server has opted in.&lt;/li&gt;&#10;&lt;li&gt;Its index covers 2,025 documents from the estate&amp;rsquo;s public projects.&lt;/li&gt;&#10;&lt;li&gt;A round of tuning across 142 real questions cut the ones with no answering passage from 22 to 12, and lifted the questions where the top passage answered it from 61 to 76.&lt;/li&gt;&#10;&lt;li&gt;19 releases of the bot since August 2026, built on &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; and answering through &lt;a class="link" href="https://phpboyscout.uk/showcase/chat/" &gt;chat&lt;/a&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it as a pattern if you run a community around your own docs and want questions answered from them, with sources, without letting a model make things up.&lt;/p&gt;&#10;&lt;p&gt;It doesn&amp;rsquo;t moderate or raise GitLab issues yet, it works for one project&amp;rsquo;s servers, only in public channels, with keyword retrieval rather than semantic search, and only with Anthropic models. Answers are only as current as its last refresh, and releases are checksummed but not yet signed.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Following up inside a thread and a way for a member to ask to be forgotten are both drafted. Local inference has landed but isn&amp;rsquo;t used yet, and the hybrid retrieval that would use it, finding a page that answers a question even when they share no words, is in review. Posting everywhere waits on thirty human-reviewed answers to real questions (&lt;a class="link" href="https://phpboyscout.uk/the-bot-couldnt-answer-it-neither-could-i/" &gt;The bot couldn&amp;rsquo;t answer it. Neither could I.&lt;/a&gt;).&lt;/p&gt;&#10;</description></item><item><title>rust-tool-base</title><link>https://phpboyscout.uk/showcase/rust-tool-base/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/rust-tool-base/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;A confession first. rust-tool-base started as a vanity project and a way of learning Rust properly, begun not long after go-tool-base went open source, back when go-tool-base was still a single monolithic project. Since then the Go side of the estate has grown enormously and kept accelerating, and the Rust side couldn&amp;rsquo;t keep pace, so it&amp;rsquo;s lagging behind. It isn&amp;rsquo;t forgotten, though. It&amp;rsquo;s still very much on the radar, and it&amp;rsquo;ll get more love as soon as there&amp;rsquo;s more time (and more runner capacity and AI quota) to give it.&lt;/p&gt;&#10;&lt;p&gt;With that said, here&amp;rsquo;s what it&amp;rsquo;s for. Rust has superb single-purpose crates and no ready-made way of putting them together into a tool people install and keep updated: configuration, self-update, docs, credentials, AI, MCP and telemetry, all agreeing with each other. That&amp;rsquo;s the gap &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; fills for Go, and rust-tool-base fills it for Rust.&lt;/p&gt;&#10;&lt;p&gt;It isn&amp;rsquo;t a port, though. &amp;ldquo;Same idea, different idioms&amp;rdquo; is the whole point: the same outcomes for a tool&amp;rsquo;s users, reached the way Rust prefers, which turns out to be quite a different shape. &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0008-rust-tool-base" target="_blank" rel="noopener"&#10; &gt;Its spec&lt;/a&gt; carries a ten-row table of the places the two part company.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="whats-shared-and-what-isnt"&gt;What&amp;rsquo;s shared, and what isn&amp;rsquo;t&#10;&lt;/h3&gt;&lt;p&gt;The two frameworks share outcomes, names (the forge and chat crates were renamed to match their Go modules), the minisign signature format and the shape of a repository. They don&amp;rsquo;t share code, a runtime, or even a config format: Go&amp;rsquo;s tools read YAML and Rust&amp;rsquo;s read TOML.&lt;/p&gt;&#10;&lt;p&gt;Where they differ is where Rust earns its keep. Configuration is typed instead of looked up by string, builders refuse to compile when a required field is missing, commands register themselves at link time, and errors are values you can match on. A tool is its commands, a registration and one call to build the application.&lt;/p&gt;&#10;&lt;h3 id="one-crate-per-idea"&gt;One crate per idea&#10;&lt;/h3&gt;&lt;p&gt;Each library is its own crate in its own repository under &lt;a class="link" href="https://rust.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;rust&lt;/a&gt;, all published to crates.io with the &lt;code&gt;rtb-&lt;/code&gt; prefix, and an umbrella crate switches them on with feature flags and pins a set of versions known to work together. It releases with release-plz rather than &lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt;, for now: &lt;a class="link" href="https://gitlab.com/phpboyscout/colophon/-/work_items/37" target="_blank" rel="noopener"&#10; &gt;colophon#37&lt;/a&gt; is working out what it would take to change that.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Twelve small repositories.&lt;/strong&gt; The framework was split into roughly a dozen repos, one concept each, over about six larger ones or Go&amp;rsquo;s one-repo-per-plugin layout (&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0043-multi-repo-decomposition" target="_blank" rel="noopener"&#10; &gt;spec 0043&lt;/a&gt;). Before the split, one merge request&amp;rsquo;s pipeline ran about 51 minutes of compute. &lt;em&gt;What it cost:&lt;/em&gt; a dozen docs sites, more load on the runners, and version skew that shows up as two copies of one crate in a build, which makes automated updates and a duplicate-crate ban part of correctness rather than tidiness.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Its own config store.&lt;/strong&gt; Config is moving to a native store of its own, replacing figment, with string getters refused for good (&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0044-rtb-config-v2-store-architecture" target="_blank" rel="noopener"&#10; &gt;spec 0044&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a hard break, taken now because there&amp;rsquo;s nobody downstream to break yet.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Go&amp;rsquo;s design, Rust&amp;rsquo;s wiring.&lt;/strong&gt; Forge access follows go/forge&amp;rsquo;s vendor-free design, but providers are passed in explicitly instead of through a global registry (&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0042-gtb-ecosystem-parity-phase3" target="_blank" rel="noopener"&#10; &gt;spec 0042&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; splitting the forge and chat crates per provider was deferred, so the forge crate still carries all six backends.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;Every crate is on crates.io, none yanked, with the umbrella at 0.9 and the libraries between 0.6 and 0.9. Its release binaries are signed with minisign through KMS, the same way as everything in &lt;a class="link" href="https://phpboyscout.uk/showcase/signing-and-trust/" &gt;Signing and trust&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;31 posts so far, starting with &lt;a class="link" href="https://phpboyscout.uk/rust-tool-base-the-same-idea/" &gt;the same idea, in a language that argues back&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;Nothing outside the family depends on it yet, which is what you&amp;rsquo;d expect of the part of the estate that&amp;rsquo;s been waiting its turn.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you&amp;rsquo;re building a command-line tool in Rust and want config, self-update, docs, credentials and AI wired together the way go-tool-base does it for Go, and you&amp;rsquo;re comfortable being an early adopter.&lt;/p&gt;&#10;&lt;p&gt;Don&amp;rsquo;t use it yet if you need stability: it&amp;rsquo;s pre-1.0 and the API isn&amp;rsquo;t frozen, release binaries are Linux x86_64 only, and the &lt;a class="link" href="https://rtb.phpboyscout.uk/reference/limitations/" target="_blank" rel="noopener"&#10; &gt;limitations page&lt;/a&gt; lists seventeen gaps, nested subcommands among them. Like its Go sibling, it&amp;rsquo;s not a web framework.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Catching up, as time, runner capacity and AI quota allow. The new config store is approved and still to be built (&lt;a class="link" href="https://gitlab.com/phpboyscout/rust/config/-/work_items/10" target="_blank" rel="noopener"&#10; &gt;rust/config#10&lt;/a&gt;), and splitting the forge crate per provider comes after that (&lt;a class="link" href="https://gitlab.com/phpboyscout/rust/forge/-/work_items/9" target="_blank" rel="noopener"&#10; &gt;rust/forge#9&lt;/a&gt;).&lt;/p&gt;&#10;</description></item><item><title>Secrets in use</title><link>https://phpboyscout.uk/showcase/secrets-in-use/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/secrets-in-use/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Every tool that talks to a service needs a token, and the easy place to put one is the config file. That&amp;rsquo;s the threat go/credentials is written against: &amp;ldquo;a plaintext secret committed to, or left in, a config file. Config files get checked into git, copied between machines, attached to bug reports, and synced to backups.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;The second problem is quieter. Once a token can come from several places (an environment variable, the OS keychain, a value in the file), something has to decide which one wins. When every library decides for itself, a program ends up with several precedence orders stacked inside each other, and nobody can say which token it&amp;rsquo;s actually using. The forge library had exactly that, and its spec put it plainly: &amp;ldquo;Two precedence systems in one path is how configuration becomes unpredictable&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0005-credential-source-composition" target="_blank" rel="noopener"&#10; &gt;forge spec 0005&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="a-credential-is-a-source-not-a-string"&gt;A credential is a source, not a string&#10;&lt;/h3&gt;&lt;p&gt;A library that needs a secret takes a function it can call when it needs one, never a string handed over at start-up. The secret is looked up at the moment of use rather than carried around, and the library doesn&amp;rsquo;t care where it came from. Returning nothing means &amp;ldquo;not configured&amp;rdquo;, and returning an error means &amp;ldquo;configured but broken&amp;rdquo;, so a keychain that won&amp;rsquo;t unlock never reads as a missing token. &lt;a class="link" href="https://phpboyscout.uk/showcase/git-and-forges/" &gt;Git and forges&lt;/a&gt; and go/nats take credentials this way, and the reason is in nats&amp;rsquo; own words: &amp;ldquo;precedence is the consumer&amp;rsquo;s decision, not a library&amp;rsquo;s.&amp;rdquo;&lt;/p&gt;&#10;&lt;h3 id="the-order-is-decided-once-by-the-application"&gt;The order is decided once, by the application&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; states its order in one place: an environment variable it&amp;rsquo;s been told the name of, then the keychain, then a value in the file, then the variable a vendor&amp;rsquo;s own tools would use. Both the code that supplies a token and the &lt;code&gt;doctor&lt;/code&gt; command that reports on it build from that one statement, so the two can&amp;rsquo;t disagree about which source wins (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0189-credential-lifecycle" target="_blank" rel="noopener"&#10; &gt;spec 0189&lt;/a&gt;). &lt;code&gt;doctor&lt;/code&gt; shows what&amp;rsquo;s stored, where each credential resolves from and what&amp;rsquo;s being shadowed, and none of that report is a credential value. Each source is only consulted when it&amp;rsquo;s needed, so a repository reached over SSH never triggers a keychain prompt for a token it doesn&amp;rsquo;t need.&lt;/p&gt;&#10;&lt;p&gt;When a keychain entry is configured but can&amp;rsquo;t be read, it refuses rather than falling back to a plaintext value in the file. The default storage choice reads the environment too. It uses the keychain only outside CI, at a real terminal, and after a test write succeeds. Everywhere else it records the name of an environment variable.&lt;/p&gt;&#10;&lt;h3 id="where-to-keep-it"&gt;Where to keep it&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://credentials.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/credentials&lt;/a&gt; is the storage half. A config file records one of three things: the name of an environment variable, a keychain reference, or, as a last resort, the secret itself. Storing the secret itself isn&amp;rsquo;t offered in CI, and is refused there if it&amp;rsquo;s chosen anyway. The OS keychain is opt-in: leave it out and the keychain libraries never enter the binary, which you can prove from the build&amp;rsquo;s software bill of materials. The module deliberately has no resolver and no precedence order, because &amp;ldquo;the order in which your tool consults those sources at runtime is your tool&amp;rsquo;s policy, and it is written in your tool.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/config/" &gt;config&lt;/a&gt; treats secrets stores as layers marked sensitive. The secrets managers (Vault, AWS, Google Cloud and Azure) are read-only, and a write that would copy one of their values into a plain file is refused. The keychain layer is the one sensitive layer you can write to: a token goes into the keychain, and one already there can never be written to the file beneath it. A shared test suite holds every sensitive backend to that rule.&lt;/p&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://vaultclient.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/vaultclient&lt;/a&gt; builds a Vault client four ways, from your own client to the ambient environment, and never logs the token at any level. A test sets a token and fails if it turns up in the output.&lt;/p&gt;&#10;&lt;h3 id="scrubbed-on-the-way-out"&gt;Scrubbed on the way out&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://redact.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/redact&lt;/a&gt; masks secrets in text before it leaves the process: passwords in URLs, &lt;code&gt;name=value&lt;/code&gt; pairs and JSON fields with credential names, authorisation headers, JWTs, known token prefixes from GitHub, GitLab, AWS, Google, OpenAI and Slack, and long opaque tokens. Its rule is &amp;ldquo;Sanitise where data leaves the host, not everywhere.&amp;rdquo; A second pass changes nothing, so an outer boundary can scrub again &amp;ldquo;without coordination&amp;rdquo;, even when an inner one already has (&lt;a class="link" href="https://redact.go.phpboyscout.uk/explanation/threat-model/" target="_blank" rel="noopener"&#10; &gt;threat model&lt;/a&gt;). &lt;a class="link" href="https://phpboyscout.uk/showcase/transport-stack/" &gt;transit&lt;/a&gt; runs every outbound URL it logs through it, and always masks sensitive header values, even when a caller asked for them to be logged. The rules can&amp;rsquo;t be switched off, because &amp;ldquo;a redactor whose rules can be weakened by the environment is a redactor that can be switched off by accident.&amp;rdquo;&lt;/p&gt;&#10;&lt;h3 id="and-in-the-pipeline"&gt;And in the pipeline&#10;&lt;/h3&gt;&lt;p&gt;gitleaks runs in every language track&amp;rsquo;s security component, scanning only the commits a merge request adds, with its own output redacted: &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-security/" target="_blank" rel="noopener"&#10; &gt;go-security&lt;/a&gt; on 99 projects, &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/rust/rust-security/" target="_blank" rel="noopener"&#10; &gt;rust-security&lt;/a&gt; on 12, &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/tofu/tofu-security/" target="_blank" rel="noopener"&#10; &gt;tofu-security&lt;/a&gt; on 7, and the &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/svelte/svelte-security/" target="_blank" rel="noopener"&#10; &gt;svelte&lt;/a&gt; and &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/skills/skill-security/" target="_blank" rel="noopener"&#10; &gt;skills&lt;/a&gt; components. 119 of the 139 projects with a pipeline run at least one.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;No ladders in libraries.&lt;/strong&gt; forge&amp;rsquo;s built-in precedence was removed and the order moved into the application (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0005-credential-source-composition" target="_blank" rel="noopener"&#10; &gt;forge spec 0005&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; config reads every backend when it starts, so a program built on config alone unlocks every credential at start-up whether it uses them or not. go-tool-base avoids that by making each source lazy.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A function, not an interface.&lt;/strong&gt; Every real credential source is a one-liner, and an interface would make each one declare a type. &lt;em&gt;What it cost:&lt;/em&gt; go/nats and go/forge each define the same function shape rather than sharing one.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The caller&amp;rsquo;s deadline.&lt;/strong&gt; A slow keychain is abandoned when the caller gives up, and there&amp;rsquo;s no default timeout because &amp;ldquo;no single value is defensible&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/credentials/-/wikis/specs/0001-keychain-honours-context" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; an abandoned save can still finish after you&amp;rsquo;ve moved on.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Fixed redaction rules.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; false positives such as &lt;code&gt;sort-key=***&lt;/code&gt;, and short or bespoke secrets get through.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;go-tool-base, keryx, krites and phpbotscout store credentials through go/credentials. go/redact is used directly by go-tool-base, phpbotscout, forge, transport and transit, and by 16 more modules through them.&lt;/li&gt;&#10;&lt;li&gt;7 releases of go/credentials since July 2026, 6 of go/redact, and 14 of the keychain config layer.&lt;/li&gt;&#10;&lt;li&gt;The guarantees are tests: the keychain stays out of the core&amp;rsquo;s dependency graph, Vault&amp;rsquo;s token never reaches a log, and redaction is fuzz-tested.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you&amp;rsquo;re building a Go tool other people install, and you want their tokens out of config files and out of your logs, without picking a credential order on their behalf.&lt;/p&gt;&#10;&lt;p&gt;It isn&amp;rsquo;t a secrets manager. go/credentials doesn&amp;rsquo;t rotate, cache, retry or audit, and holds one keychain backend per process. go/redact recognises shapes, not randomness, so a short secret with no telling name gets through, and it doesn&amp;rsquo;t parse YAML or JSON. A Vault token doesn&amp;rsquo;t renew itself, so a long-running process fails once it expires. go/errors doesn&amp;rsquo;t redact on purpose: keeping values out of errors is a convention, and go/redact is applied at the boundary. One place doesn&amp;rsquo;t follow the rule yet: go/chat still looks a provider&amp;rsquo;s API key up through its own fixed order.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Holding a credential that expires and has to be renewed in a long-running process (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/credentials/-/work_items/5" target="_blank" rel="noopener"&#10; &gt;go/credentials#5&lt;/a&gt;), OAuth and SSH credentials in go-tool-base (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/work_items/121" target="_blank" rel="noopener"&#10; &gt;go-tool-base#121&lt;/a&gt;), keryx moving its secrets into the keychain layer (&lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/work_items/7" target="_blank" rel="noopener"&#10; &gt;keryx#7&lt;/a&gt;), go/chat handing its key order to the application (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/chat/-/work_items/35" target="_blank" rel="noopener"&#10; &gt;go/chat#35&lt;/a&gt;), and transit&amp;rsquo;s docs catching up with the masking it already does (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/transit/-/work_items/7" target="_blank" rel="noopener"&#10; &gt;transit#7&lt;/a&gt;).&lt;/p&gt;&#10;</description></item><item><title>Secure artefact delivery</title><link>https://phpboyscout.uk/showcase/secure-artefact-delivery/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/secure-artefact-delivery/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;A tool that needs a machine-learning model or a native runtime usually downloads it from the vendor&amp;rsquo;s URL and checks it against a SHA-256 that somebody pasted into the source. That digest can&amp;rsquo;t be rotated or revoked, it says nothing about who published the file, and it doesn&amp;rsquo;t scale. As go/onnxruntime&amp;rsquo;s docs put it, a table of four platforms across three versions is twelve constants, and &amp;ldquo;in practice one person verifies the first and copies the pattern&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;The channel carries twelve files today, ten runtime archives and two models. Each of those would otherwise be a hand-typed constant in every tool that uses it.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="approve-it-once-in-a-merge-request"&gt;Approve it once, in a merge request&#10;&lt;/h3&gt;&lt;p&gt;Adding an artefact to &lt;a class="link" href="https://artifacts.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;the channel&lt;/a&gt; is a merge request against one list. Once it merges, the pipeline fetches the file from upstream, checks it, records its digest and size, signs a manifest for it with a KMS key it reaches through OIDC (so there&amp;rsquo;s no stored credential anywhere), and publishes it.&lt;/p&gt;&#10;&lt;h3 id="check-the-signature-before-downloading"&gt;Check the signature before downloading&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://artifacts.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/artifacts&lt;/a&gt; is the client a tool uses, and it checks three things in order, the first two before it downloads the file at all:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;a signed index, which has to list that exact version as approved&lt;/li&gt;&#10;&lt;li&gt;the manifest&amp;rsquo;s signature, against both the key built into the tool and the key published over WKD, which have to agree&lt;/li&gt;&#10;&lt;li&gt;the file&amp;rsquo;s digest and size, once it arrives&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://onnxruntime.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/onnxruntime&lt;/a&gt; sits on top for the ONNX Runtime specifically, resolving the right archive for the platform. There isn&amp;rsquo;t a pasted digest anywhere in it.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;A manifest per version.&lt;/strong&gt; Every artefact-version gets a manifest of its own, signed and published when its merge request lands, instead of one manifest over everything cut from repository tags (&lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/wikis/specs/0014-approved-artefact-distribution" target="_blank" rel="noopener"&#10; &gt;spec 0014&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; the signing key now trusts the main branch, not just tag pipelines, so an approved merge can mint a signature.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Exact versions, no &amp;ldquo;stable&amp;rdquo;.&lt;/strong&gt; Tools pin exact versions. A floating &amp;ldquo;stable&amp;rdquo; pointer was turned down, because a moved pointer still verifies, so nobody would notice it had moved. &lt;em&gt;What it cost:&lt;/em&gt; every upgrade is a deliberate, approved bump.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The index is required.&lt;/strong&gt; There&amp;rsquo;s no fallback mode that skips the signed index when it can&amp;rsquo;t be reached, and an index expires after 24 hours (&lt;a class="link" href="https://gitlab.com/phpboyscout/phpbotscout/-/wikis/specs/0016-signed-artefact-index" target="_blank" rel="noopener"&#10; &gt;spec 0016&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; the host is a hard dependency. When the job that keeps the index fresh wasn&amp;rsquo;t running, the channel failed closed for about a day and a half, which is the design doing its job, with every pipeline still green.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One bucket, on a neutral name.&lt;/strong&gt; The channel moved to object storage on a hostname that names no provider (&lt;a class="link" href="https://gitlab.com/phpboyscout/artifacts/-/wikis/specs/0018-move-the-channel-to-object-storage" target="_blank" rel="noopener"&#10; &gt;spec 0018&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; the location is part of what&amp;rsquo;s signed, so the move meant re-signing everything and waiting a day for the old indexes to expire.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://krites.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;krites&lt;/a&gt; loads its runtime and its face models through it: ONNX Runtime 1.23.0 through go/onnxruntime, and the MediaPipe face models through go/artifacts.&lt;/li&gt;&#10;&lt;li&gt;The signed index is reissued continually, because a tool refuses one older than a day. It was at generation 155 on 10 October 2026.&lt;/li&gt;&#10;&lt;li&gt;The signatures use the same keys and the same WKD check as everything else in &lt;a class="link" href="https://phpboyscout.uk/showcase/signing-and-trust/" &gt;Signing and trust&lt;/a&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it as the pattern if a tool of yours downloads something large and native at runtime, and you&amp;rsquo;d rather it proved where the file came from than trusted a constant.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s deliberately narrow. It never picks a version for you (no &amp;ldquo;latest&amp;rdquo;, no listing), it doesn&amp;rsquo;t unpack or load what it fetches, and it can&amp;rsquo;t protect a file once it&amp;rsquo;s handed you the path. An outage of the host stops a fresh resolve, withdrawing an artefact can take up to a day to reach a tool, and it vouches for what was published, not that upstream wasn&amp;rsquo;t compromised before that. go/onnxruntime has no Windows build yet.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Finishing the move to object storage, which means retiring the old package registry copies and moving the last image that still fetches from there. The specs that define the channel are also due to move from phpbotscout&amp;rsquo;s wiki, where it started, to the channel&amp;rsquo;s own.&lt;/p&gt;&#10;</description></item><item><title>Signing and trust</title><link>https://phpboyscout.uk/showcase/signing-and-trust/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/signing-and-trust/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Most projects publish a checksum file next to their binaries and call it verification. It catches a download that got corrupted on the way, and that&amp;rsquo;s about all it catches, because whoever can swap the binary can swap the checksum file sitting beside it. The spec that started all of this puts it in one line: &amp;ldquo;If an attacker can replace the binary, they can also replace &lt;code&gt;checksums.txt&lt;/code&gt;.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;So the question isn&amp;rsquo;t how to publish a checksum. It&amp;rsquo;s how to prove a release came from you, when the place you publish it is exactly the place an attacker would go.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="sign-with-a-key-nobody-can-copy"&gt;Sign with a key nobody can copy&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://sigillum.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;sigillum&lt;/a&gt; signs a release in one of two formats: an OpenPGP signature over the checksum file, which &lt;code&gt;gpg&lt;/code&gt; checks, or a minisign signature beside each file, which &lt;code&gt;minisign&lt;/code&gt; checks. The key lives in a key service and never leaves it: sigillum sends a digest of what it&amp;rsquo;s signing and gets a signature back, so there&amp;rsquo;s no key file to leak, copy or forget on a laptop. (For minisign it hashes the file locally and sends only the 64-byte hash, which is how a large binary gets signed by a key that can&amp;rsquo;t leave the service.) Here that service is AWS KMS, because that&amp;rsquo;s what the estate runs on, and a local key file works too for trying it out.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s a single binary that knows nothing about what language your project is written in, which is why Rust and other non-Go pipelines can sign with it without building anything of mine first.&lt;/p&gt;&#10;&lt;h3 id="bring-your-own-key-store"&gt;Bring your own key store&#10;&lt;/h3&gt;&lt;p&gt;AWS KMS is what I use, not what it&amp;rsquo;s tied to. The whole signing family was designed from the start so the key store is a plug-in. &lt;a class="link" href="https://signing.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/signing&lt;/a&gt; defines one small contract (take a key reference, hand back a standard Go signer) and knows nothing about any cloud: each key service is a module of its own, and the core can&amp;rsquo;t even compile in a vendor SDK, because a test fails the build if one sneaks in. AWS KMS is one such module and the local key file is another, and GCP KMS, Azure Key Vault, HashiCorp Vault or a PKCS#11 HSM would each be a third, written the same way. There&amp;rsquo;s a &lt;a class="link" href="https://signing.go.phpboyscout.uk/how-to/implement-a-custom-backend/" target="_blank" rel="noopener"&#10; &gt;how-to for writing your own&lt;/a&gt;, and &lt;a class="link" href="https://encryption.go.phpboyscout.uk/how-to/implement-a-key-service/" target="_blank" rel="noopener"&#10; &gt;go/encryption&lt;/a&gt; follows the same pattern for decryption, down to a smartcard if that&amp;rsquo;s where your key lives.&lt;/p&gt;&#10;&lt;h3 id="publish-the-key-somewhere-else"&gt;Publish the key somewhere else&#10;&lt;/h3&gt;&lt;p&gt;A signature is only as good as your confidence in the public key that checks it. The key is built into the tools that verify with it, and it&amp;rsquo;s also published over &lt;a class="link" href="https://wiki.gnupg.org/WKD" target="_blank" rel="noopener"&#10; &gt;WKD&lt;/a&gt; (a well-known address on a domain I control, where &lt;code&gt;gpg&lt;/code&gt; knows to look for it). The two copies have to agree, so forging a release means breaking into the forge &lt;em&gt;and&lt;/em&gt; the key host at the same time. &lt;a class="link" href="https://phpboyscout.uk/publish-your-key-where-they-cant-touch-it/" &gt;Publish your key where they can&amp;rsquo;t touch it&lt;/a&gt; goes through why that host was picked.&lt;/p&gt;&#10;&lt;h3 id="verify-before-anything-installs"&gt;Verify before anything installs&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://signing.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/signing&lt;/a&gt; is the library that checks those signatures. It&amp;rsquo;s what &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt;&amp;rsquo;s self-update uses: an update is verified against the built-in key and the WKD copy before it replaces anything, and a tool generated with signing switched on refuses an unsigned update outright. If the WKD copy can&amp;rsquo;t be reached at all, self-update carries on with the built-in key alone, so a tool behind a broken proxy can still patch itself, but two copies that disagree always stop it. &lt;a class="link" href="https://phpboyscout.uk/showcase/krites/" &gt;krites&lt;/a&gt; is stricter about the models it downloads: there, an unreachable key host means try again later.&lt;/p&gt;&#10;&lt;h3 id="encrypted-reports-with-no-key-to-hold"&gt;Encrypted reports, with no key to hold&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://encryption.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;go/encryption&lt;/a&gt; runs the same idea the other way round, for reports somebody sends you encrypted. The private half stays in KMS, and the only thing KMS is asked to do is the one secret step of the key exchange; everything after that, including the decryption itself, happens on your machine. So a person can read a report without the key ever existing anywhere it could be stolen from. &lt;a class="link" href="https://phpboyscout.uk/two-encrypted-emails-in-twenty-years/" &gt;Two encrypted emails in twenty years&lt;/a&gt; is where it came from.&lt;/p&gt;&#10;&lt;h3 id="the-keys-as-infrastructure"&gt;The keys, as infrastructure&#10;&lt;/h3&gt;&lt;p&gt;Two OpenTofu modules create the keys and the permissions around them. &lt;a class="link" href="https://aws-signing-kms.iac.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;The signing module&lt;/a&gt; makes one KMS signing key and a role that only named CI jobs can use, through short-lived OIDC credentials. No person and no stored credential is granted signing, and a configuration that would let any job sign is refused before anything is created. &lt;a class="link" href="https://aws-encryption-kms.iac.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;The encryption module&lt;/a&gt; makes the decryption keys with no pipeline granted access to them, so reading a report is left to a person, and it refuses the key type that would break that, at plan time.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;GPG over cosign.&lt;/strong&gt; Signatures are checked with GPG and a key carried in the binary, chosen over cosign so verification still works offline and behind a firewall (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0056-remote-update-checksum-verification" target="_blank" rel="noopener"&#10; &gt;go-tool-base spec 0056&lt;/a&gt;). WKD was picked to publish the key over Keybase, key servers and DNS. &lt;em&gt;What it cost:&lt;/em&gt; there&amp;rsquo;s no transparency log, so if the forge and the key host were both broken into at once, nothing would notice.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A standalone signer.&lt;/strong&gt; Signing moved out of go-tool-base into sigillum and a small module behind it (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0176-ed25519-kms-signing" target="_blank" rel="noopener"&#10; &gt;gtb spec 0176&lt;/a&gt;, &lt;a class="link" href="https://gitlab.com/phpboyscout/sigillum/-/wikis/specs/0001-sigillum-bootstrap" target="_blank" rel="noopener"&#10; &gt;sigillum spec 0001&lt;/a&gt;), because two projects were compiling the whole framework just to sign a file (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0097-one-signing-binary-in-the-base-image" target="_blank" rel="noopener"&#10; &gt;cicd spec 0097&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; signing now has two front doors, sigillum and &lt;code&gt;gtb sign&lt;/code&gt;, over the same library.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Two keys where one would do.&lt;/strong&gt; OpenPGP signatures use RSA and minisign uses Ed25519, because the OpenPGP library can&amp;rsquo;t sign Ed25519 with a key it can&amp;rsquo;t hold in memory, which is exactly the kind of key KMS gives you. &lt;em&gt;What it cost:&lt;/em&gt; a project publishing both formats keeps two keys. &lt;a class="link" href="https://phpboyscout.uk/one-key-or-two/" &gt;One key or two&lt;/a&gt; is how that got settled.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Key stores as plug-ins.&lt;/strong&gt; The libraries depend on a contract, never on a provider, and every key service lives in its own module (&lt;a class="link" href="https://signing.go.phpboyscout.uk/explanation/dependency-inversion/" target="_blank" rel="noopener"&#10; &gt;go/signing&amp;rsquo;s design notes&lt;/a&gt;), so adding one never touches the core and nobody compiles an SDK they don&amp;rsquo;t use. &lt;em&gt;What it cost:&lt;/em&gt; sigillum picks its backends when it&amp;rsquo;s built, so a new key store means building sigillum with that module in it, and so far only AWS KMS and the local key file exist.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;ECDH for decryption, not RSA.&lt;/strong&gt; KMS&amp;rsquo;s RSA decryption only supports the padding OpenPGP doesn&amp;rsquo;t use, so reports are encrypted to a key-agreement key instead (&lt;a class="link" href="https://gitlab.com/phpboyscout/sigillum/-/wikis/specs/0004-kms-backed-openpgp-decryption" target="_blank" rel="noopener"&#10; &gt;sigillum spec 0004&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; each certificate carries two keys, one to certify and one to decrypt.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;Releases across the estate are signed with it: sigillum itself, colophon, krites and the artefact channel sign their checksum files with sigillum, rust-tool-base signs its binaries with minisign through KMS, and go-tool-base signs through its own wrapper over the same library.&lt;/li&gt;&#10;&lt;li&gt;Checked by hand on 10 October 2026: sigillum v0.5.2&amp;rsquo;s checksum signature comes back as a &lt;strong&gt;good signature&lt;/strong&gt; in &lt;code&gt;gpg&lt;/code&gt;, using the key fetched over WKD.&lt;/li&gt;&#10;&lt;li&gt;go/signing is a direct dependency of go-tool-base, keryx, krites, colophon and afmpeg, and reaches three more tools through go-tool-base. The key-service modules&amp;rsquo; release merge requests run cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-core-currency/" target="_blank" rel="noopener"&#10; &gt;go-core-currency&lt;/a&gt; check against the core&amp;rsquo;s latest release.&lt;/li&gt;&#10;&lt;li&gt;Eight tools build and publish their release binaries through cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/goreleaser/" target="_blank" rel="noopener"&#10; &gt;goreleaser component&lt;/a&gt;. colophon, krites and sigillum sign theirs with sigillum during that release, and go-tool-base signs through its own wrapper.&lt;/li&gt;&#10;&lt;li&gt;The &lt;a class="link" href="https://phpboyscout.uk/.well-known/security.txt" target="_blank" rel="noopener"&#10; &gt;security contact&lt;/a&gt; publishes its key the same way, over WKD: an RSA key to certify with and a P-256 key to decrypt to, the shape the encryption module builds.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you want people to be able to check that a release really came from you, using &lt;code&gt;gpg&lt;/code&gt; or &lt;code&gt;minisign&lt;/code&gt; they already have, and you&amp;rsquo;d rather the signing key lived somewhere it can&amp;rsquo;t be copied, whichever key store that is. It doesn&amp;rsquo;t care what your project is written in.&lt;/p&gt;&#10;&lt;p&gt;Know the limits first. Of the key stores, only AWS KMS and a local key file are built today. The others the design was made for are &lt;a class="link" href="https://gitlab.com/phpboyscout/go/signing/-/work_items/10" target="_blank" rel="noopener"&#10; &gt;still outstanding&lt;/a&gt;, and until they land, anything else is a module you&amp;rsquo;d write yourself against the same contract. There&amp;rsquo;s no transparency log, timestamping or notarisation yet, and no key rotation policy, and a minisign key can&amp;rsquo;t be revoked. The encryption module runs on OpenTofu only.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Key rotation, Linux package signing and Windows Authenticode are all drafted on &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0101-signing-key-rotation" target="_blank" rel="noopener"&#10; &gt;go-tool-base&amp;rsquo;s wiki&lt;/a&gt;, alongside the Google Cloud and Azure KMS backends. sigillum&amp;rsquo;s own next step is exposing signing to AI agents as MCP tools (&lt;a class="link" href="https://gitlab.com/phpboyscout/sigillum/-/wikis/specs/0003-mcp-tool-surface" target="_blank" rel="noopener"&#10; &gt;spec 0003&lt;/a&gt;).&lt;/p&gt;&#10;</description></item><item><title>The pipeline</title><link>https://phpboyscout.uk/showcase/the-pipeline/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/the-pipeline/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;One person can&amp;rsquo;t hand-maintain a CI file in 146 repositories. Every lint rule, security scan, release step and docs deploy would be written dozens of times and drift dozens of ways. So there&amp;rsquo;s one library of CI components instead, and 133 of those projects include it: 717 include lines between them, with the release component in 117 projects and the Go test, lint and security jobs in 100 each.&lt;/p&gt;&#10;&lt;p&gt;And the images those jobs run on used to be heavy. The lightweight jobs were pulling an image of nearly a gigabyte, 2.72 GB unpacked, carrying 2,582 scanner findings of every severity. Today they pull ci-base, under 80 MB.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="components-not-copies"&gt;Components, not copies&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://cicd.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;cicd&lt;/a&gt; is one GitLab project holding 38 CI components across nine tracks (Go, Rust, OpenTofu, docs, release and the rest), all released under one version stream. A project includes the components it needs and gets the whole job, rules and caching included. Each component has a self-test pipeline of its own, so a change is proven in cicd before it reaches anyone.&lt;/p&gt;&#10;&lt;h3 id="hardened-images-one-per-job"&gt;Hardened images, one per job&#10;&lt;/h3&gt;&lt;p&gt;The jobs run on a family of small images, one per track, built from a Wolfi base and pinned to an exact digest. A job pulls the image for its language and nothing else. &lt;a class="link" href="https://phpboyscout.uk/showcase/build-images/" &gt;Build images&lt;/a&gt; covers how they&amp;rsquo;re built, scanned and kept current.&lt;/p&gt;&#10;&lt;h3 id="runners-that-scale-to-nothing"&gt;Runners that scale to nothing&#10;&lt;/h3&gt;&lt;p&gt;The jobs run on &lt;a class="link" href="https://gitlab.com/phpboyscout/iac/terraform-aws-gitlab-runner-fleet" target="_blank" rel="noopener"&#10; &gt;a runner fleet&lt;/a&gt; of my own: one tiny manager instance always on, and spot-priced workers started on demand, from none up to four by default. Each worker takes one job, disappears after ten idle minutes, and is replaced after a hundred jobs, so a quiet evening costs next to nothing.&lt;/p&gt;&#10;&lt;h3 id="who-can-fix-it-decides-the-gate"&gt;Who can fix it decides the gate&#10;&lt;/h3&gt;&lt;p&gt;A check fails the pipeline only when the people running that pipeline can fix what it finds. The image scan is split in two for that reason, &amp;ldquo;because two different people can act on them&amp;rdquo;: findings in the operating system packages fail the build, since a rebuild clears them, while findings inside upstream tools are reported for their maintainers (&lt;a class="link" href="https://cicd.phpboyscout.uk/reference/automation/image-scan/" target="_blank" rel="noopener"&#10; &gt;image-scan&lt;/a&gt;). The skills gate works the same way. Hidden characters in a skill fail outright, and a suspicious-looking instruction is flagged for a person to read. Where a check is advisory, the component says why in its own header, because a job allowed to fail also hides a job that&amp;rsquo;s broken.&lt;/p&gt;&#10;&lt;h3 id="security-runs-on-every-change"&gt;Security runs on every change&#10;&lt;/h3&gt;&lt;p&gt;Most jobs skip a merge request that can&amp;rsquo;t have affected them, so a docs change doesn&amp;rsquo;t run the Go tests. The security jobs never skip, because &amp;ldquo;A dependency&amp;rsquo;s vulnerability status isn&amp;rsquo;t a function of the diff&amp;rdquo;, it&amp;rsquo;s a function of time (&lt;a class="link" href="https://cicd.phpboyscout.uk/explanation/security-always-on/" target="_blank" rel="noopener"&#10; &gt;security, always on&lt;/a&gt;). A docs-only change merged the day after a vulnerability is published is exactly the one the scanner should see. The one exception came from measuring it: re-scanning colophon&amp;rsquo;s release merge requests, which change only the changelog, found nothing in 790 pipelines over three weeks and cost a fifth of the estate&amp;rsquo;s merge-request minutes.&lt;/p&gt;&#10;&lt;h3 id="finishing-what-the-tools-start"&gt;Finishing what the tools start&#10;&lt;/h3&gt;&lt;p&gt;Several components exist to carry one of the estate&amp;rsquo;s tools into every pipeline, so the tool does the work and the component makes it happen on time, everywhere:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://cicd.phpboyscout.uk/reference/automation/colophon/" target="_blank" rel="noopener"&#10; &gt;colophon&lt;/a&gt; runs &lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt;&amp;rsquo;s release jobs in 118 projects, and &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/goreleaser/" target="_blank" rel="noopener"&#10; &gt;goreleaser&lt;/a&gt; builds and publishes the binaries of the eight tools that ship them, &lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt; and the tools built on it.&lt;/li&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-core-currency/" target="_blank" rel="noopener"&#10; &gt;go-core-currency&lt;/a&gt; checks, on 98 projects&amp;rsquo; release merge requests, that each family member is built against its core&amp;rsquo;s latest release, reading the families from this site&amp;rsquo;s own projects feed. That&amp;rsquo;s what keeps families like &lt;a class="link" href="https://phpboyscout.uk/showcase/config/" &gt;config&lt;/a&gt;, &lt;a class="link" href="https://phpboyscout.uk/showcase/chat/" &gt;chat&lt;/a&gt; and &lt;a class="link" href="https://phpboyscout.uk/showcase/git-and-forges/" &gt;git and forges&lt;/a&gt; in step.&lt;/li&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://cicd.phpboyscout.uk/reference/skills/skill-security/" target="_blank" rel="noopener"&#10; &gt;skill-security&lt;/a&gt; is the security gate on the &lt;a class="link" href="https://phpboyscout.uk/showcase/agent-skills/" &gt;agent skills&lt;/a&gt; marketplace, and &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/quality/docs-verify/" target="_blank" rel="noopener"&#10; &gt;docs-verify&lt;/a&gt; runs a project&amp;rsquo;s own docs check, docscheck in krites&amp;rsquo; case, on every merge request.&lt;/li&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-singleuse/" target="_blank" rel="noopener"&#10; &gt;go-singleuse&lt;/a&gt; runs the &lt;a class="link" href="https://phpboyscout.uk/showcase/controls/" &gt;controls&lt;/a&gt; analyzer for restarts that would reuse something they can&amp;rsquo;t, and &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/tofu/tofu-module-publish/" target="_blank" rel="noopener"&#10; &gt;tofu-module-publish&lt;/a&gt; publishes the &lt;a class="link" href="https://phpboyscout.uk/showcase/aws-account-foundation/" &gt;AWS account foundation&lt;/a&gt; modules to GitLab&amp;rsquo;s module registry.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;One library.&lt;/strong&gt; Every component lives in one project (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0001-cicd-v0.1" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; every release reaches every consumer, which is a real load on the runners when it happens several times a week.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Track main, on purpose.&lt;/strong&gt; Projects include the components from &lt;code&gt;main&lt;/code&gt; instead of a pinned tag (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0079-pipeline-churn-on-three-runner-slots" target="_blank" rel="noopener"&#10; &gt;spec 0079&lt;/a&gt;). With 106 repositories consuming cicd, 65 releases in 90 days had each been costing about a day of runner time in churn, and GitLab&amp;rsquo;s catalog and a stable branch were both turned down as the alternative. &lt;em&gt;What it cost:&lt;/em&gt; it goes against GitLab&amp;rsquo;s own supply-chain advice, knowingly, and it puts the weight on cicd&amp;rsquo;s self-tests.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;133 of the estate&amp;rsquo;s 146 active projects include cicd, and &lt;a class="link" href="https://phpboyscout.uk/showcase/colophon/" &gt;colophon&lt;/a&gt; releases 117 of them through it.&lt;/li&gt;&#10;&lt;li&gt;cicd is at v0.55, with 83 releases, and the images release on their own (&lt;a class="link" href="https://phpboyscout.uk/showcase/build-images/" &gt;Build images&lt;/a&gt;).&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it as a model, or borrow components from it, if you run a lot of small GitLab projects on your own and want one place to change a pipeline.&lt;/p&gt;&#10;&lt;p&gt;It isn&amp;rsquo;t a general-purpose product, and the &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/limitations/" target="_blank" rel="noopener"&#10; &gt;limitations page&lt;/a&gt; says so. It&amp;rsquo;s GitLab only, with no path to GitHub Actions, there&amp;rsquo;s no Python, PHP or Java track, the OpenTofu components sign in to AWS only, sites deploy to GitLab Pages only, and minor versions can still break things before 1.0. The runner fleet runs x86 jobs only, runs its workers privileged, and was built for one account.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;The open issues are mostly the fleet&amp;rsquo;s: build caches that don&amp;rsquo;t like being shared by concurrent jobs, and a GitHub mirror that keeps failing.&lt;/p&gt;&#10;</description></item><item><title>Transport stack</title><link>https://phpboyscout.uk/showcase/transport-stack/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/transport-stack/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Every Go service needs the same handful of things: TLS that isn&amp;rsquo;t weak, timeouts, health probes, authentication, security headers, retries and circuit breaking, and logging and tracing on both the server and the clients it calls. Done piecemeal, each one is another chance to get a default wrong.&lt;/p&gt;&#10;&lt;p&gt;Defaults really are where it bites. Measured on this stack&amp;rsquo;s own server before it was fixed, a 13-second stream delivered 10 of its 13 frames, and the access log recorded a success, with a byte count higher than the client ever received (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/transport/-/wikis/specs/0001-streaming-and-timeout-policy" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="one-posture-everywhere"&gt;One posture, everywhere&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://tls.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;tls&lt;/a&gt; sets one TLS posture (1.2 at minimum, six chosen cipher suites) under the HTTP server, the gRPC server and both client factories, and &lt;a class="link" href="https://transit.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;transit&lt;/a&gt; is one middleware module that the server and the clients share. So the logging, tracing, retries and breakers on either end of a call come from the same place.&lt;/p&gt;&#10;&lt;h3 id="clients-and-servers-apart"&gt;Clients and servers apart&#10;&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://transport.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;transport&lt;/a&gt; is the server side (HTTP, gRPC and a REST gateway) on top of tls, &lt;a class="link" href="https://authn.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;authn&lt;/a&gt; and transit, and &lt;a class="link" href="https://httpclient.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;httpclient&lt;/a&gt; and &lt;a class="link" href="https://grpcclient.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;grpcclient&lt;/a&gt; are the client side, in separate modules, so a program that only makes calls never links server code. The heavy extras are opt-in: an embedded API-docs UI, Prometheus metrics, and &lt;a class="link" href="https://localca.go.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;localca&lt;/a&gt; for trusted HTTPS on your own machine. Every one of the nine fails its build if a framework, a CLI library or a cloud SDK sneaks into its dependencies.&lt;/p&gt;&#10;&lt;h3 id="each-guard-on-the-side-it-protects"&gt;Each guard on the side it protects&#10;&lt;/h3&gt;&lt;p&gt;Where a guard sits is part of the design. A rate limiter protects whatever is receiving the load, so it goes on the server. A circuit breaker protects a caller from a service that&amp;rsquo;s failing, so it goes on the client, and the framework offers neither the other way round, because &amp;ldquo;putting either on the wrong side is a category error&amp;rdquo; (&lt;a class="link" href="https://gtb.phpboyscout.uk/explanation/concepts/transport-middleware/" target="_blank" rel="noopener"&#10; &gt;transport middleware&lt;/a&gt;). On the client, the breaker sits outside retry. One call that retries three times and still fails counts as one failure, not three, so the breaker doesn&amp;rsquo;t trip too eagerly, and once it&amp;rsquo;s open, a call is refused before any time is spent retrying (&lt;a class="link" href="https://transit.go.phpboyscout.uk/explanation/middleware-model/" target="_blank" rel="noopener"&#10; &gt;transit&amp;rsquo;s middleware model&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Light clients, heavy server.&lt;/strong&gt; The stack was split so client modules stay small, rather than one module dragging lifecycle, auth and gateway code into every program that only calls out (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0123-transport-stack-extraction-plan" target="_blank" rel="noopener"&#10; &gt;gtb spec 0123&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; nine modules to keep in step, which grouped automated updates take care of.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A clean break.&lt;/strong&gt; The server stack left go-tool-base as a clean break, with no facade re-exporting the old names (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0129-transport-server-stack" target="_blank" rel="noopener"&#10; &gt;gtb spec 0129&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; a breaking change downstream, eased by a symbol-by-symbol migration note.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Fix the logs, not a subsystem.&lt;/strong&gt; The streaming problem above was fixed by repairing the logging middleware that hid failed writes, documenting how a stream outlives a timeout, and adding a small helper for server-sent events, instead of building a streaming subsystem (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/transport/-/wikis/specs/0001-streaming-and-timeout-policy" target="_blank" rel="noopener"&#10; &gt;spec 0001&lt;/a&gt;). &lt;em&gt;What it cost:&lt;/em&gt; safe streaming still depends on reading the docs.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The body cap stays on.&lt;/strong&gt; Every route keeps a 1 MiB request-body cap by default, and a single handler can raise it for its own request (&lt;a class="link" href="https://gitlab.com/phpboyscout/go/transport/-/wikis/specs/0002-per-route-request-body-cap" target="_blank" rel="noopener"&#10; &gt;spec 0002&lt;/a&gt;), because dropping the server-wide cap would quietly undo a security-audit finding. &lt;em&gt;What it cost:&lt;/em&gt; the cap is no longer a pure wrapper.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/showcase/go-tool-base/" &gt;go-tool-base&lt;/a&gt;, keryx, krites and phpbotscout serve through it, and &lt;a class="link" href="https://phpboyscout.uk/showcase/git-and-forges/" &gt;go/forge&lt;/a&gt; and its four adapters make their calls through httpclient.&lt;/li&gt;&#10;&lt;li&gt;75 releases across the nine modules since July 2026, with every consumer on the current one. Every module&amp;rsquo;s release merge request runs cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/go/go-core-currency/" target="_blank" rel="noopener"&#10; &gt;go-core-currency&lt;/a&gt; check, which flags a release still built against an older transport core.&lt;/li&gt;&#10;&lt;li&gt;Nearly 600 test functions across the stack.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it if you&amp;rsquo;re writing Go services and clients and want hardened defaults that agree with each other, without adopting a whole web framework.&lt;/p&gt;&#10;&lt;p&gt;It reads no config files or environment variables and reloads nothing, renewed certificates included. There&amp;rsquo;s no HTTP/2 over plain text, no Unix sockets, no mutual TLS on the gRPC client, and httpclient doesn&amp;rsquo;t negotiate HTTP/2 or retry a POST. The breakers and rate limiters don&amp;rsquo;t coordinate across replicas, authn refuses shared-secret tokens and has no revocation, and localca&amp;rsquo;s Windows trust-store install doesn&amp;rsquo;t work yet.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;Making a server that dies after a clean start visible to its supervisor, and masking credential names in request logs, are the next fixes.&lt;/p&gt;&#10;</description></item><item><title>Web front ends</title><link>https://phpboyscout.uk/showcase/web-front-ends/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/showcase/web-front-ends/</guid><description>&lt;h2 id="the-problem-it-exists-for"&gt;The problem it exists for&#10;&lt;/h2&gt;&lt;p&gt;Three tools in the estate have a full web UI. &lt;a class="link" href="https://phpboyscout.uk/showcase/keryx/" &gt;keryx&lt;/a&gt;&amp;rsquo;s studio is where reels and social posts get made, &lt;a class="link" href="https://phpboyscout.uk/showcase/krites/" &gt;krites&lt;/a&gt;&amp;rsquo; studio is where a photo shoot gets culled, and &lt;a class="link" href="https://scoutdm.com/" target="_blank" rel="noopener"&#10; &gt;Scout.DM&lt;/a&gt;&amp;rsquo;s portal is where a DM runs a campaign. Each one ships as a single Go binary, and the UI has to travel with it: no separate web server to install, no Node on the user&amp;rsquo;s machine, nothing to keep in step. Each one is also a front end onto a tool that already works from the command line, so the UI mustn&amp;rsquo;t become a second place where behaviour lives.&lt;/p&gt;&#10;&lt;h2 id="how-it-works"&gt;How it works&#10;&lt;/h2&gt;&lt;h3 id="svelte-not-react-or-angular"&gt;Svelte, not React or Angular&#10;&lt;/h3&gt;&lt;p&gt;The choice was made for krites first, and written down there. Svelte &amp;ldquo;compiles to tiny vanilla JS, no virtual-DOM runtime&amp;rdquo;, which keeps the footprint and memory low and embeds cleanly in a Go binary, and the spec&amp;rsquo;s advice is to &amp;ldquo;Avoid a heavyweight React/Next stack for a localhost tool&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/wikis/specs/0001-krites" target="_blank" rel="noopener"&#10; &gt;krites spec 0001&lt;/a&gt;). Vue was weighed and lost on footprint, and pure-Go native toolkits were turned down as an unfamiliar stack with a hand-built grid. The API in front of the UI doesn&amp;rsquo;t care which framework calls it, so &amp;ldquo;the choice stays reversible&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;keryx took the same stack from krites, and Scout.DM chose Svelte for its portal too. All three now run Svelte 5 on Vite 8, with Svelte&amp;rsquo;s runes for state rather than stores.&lt;/p&gt;&#10;&lt;h3 id="plain-svelte-first-sveltekit-where-it-earns-it"&gt;Plain Svelte first, SvelteKit where it earns it&#10;&lt;/h3&gt;&lt;p&gt;keryx and krites are single-page apps in plain Svelte, with no router. keryx&amp;rsquo;s three screens are &amp;ldquo;conditional views&amp;rdquo;, and a router is to be reconsidered only &amp;ldquo;if state-sharing pain appears&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/wikis/specs/0011-studio" target="_blank" rel="noopener"&#10; &gt;keryx spec 0011&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;Scout.DM&amp;rsquo;s portal is bigger: more pages, two separate layouts, sign-in, and it&amp;rsquo;s reachable from outside the machine. So before choosing, the same four-route app was built both ways under the portal&amp;rsquo;s content security policy. Plain Svelte needed about 60 hand-written lines of routing, loading, focus handling and error pages for four routes, &amp;ldquo;and they shipped with a bug&amp;rdquo;: the old page stayed on screen showing the new page&amp;rsquo;s data. SvelteKit handled all of that itself and needed two hashes added to the server&amp;rsquo;s policy. So the portal is SvelteKit 3 with the static adapter, built to plain files with no server-side code, and a guard script refuses any SvelteKit file that would need a server to run.&lt;/p&gt;&#10;&lt;h3 id="compiled-into-the-binary"&gt;Compiled into the binary&#10;&lt;/h3&gt;&lt;p&gt;Each UI is built to static files and compiled into its Go binary. A placeholder page is committed in their place, so the binary still builds on a machine with no Node, and the placeholder tells whoever sees it to install a release. The release pipeline refuses to ship it. keryx found out why the hard way: without that guard, a build image missing Node &amp;ldquo;would build, sign, notarize and publish a release serving the &amp;lsquo;install a release for the full UI&amp;rsquo; placeholder, entirely green&amp;rdquo;, and no Go test would notice, because they pass the same either way. cicd&amp;rsquo;s &lt;a class="link" href="https://cicd.phpboyscout.uk/reference/svelte/svelte-build/" target="_blank" rel="noopener"&#10; &gt;Svelte components&lt;/a&gt; build, lint, test and scan each UI, and the release waits for the build.&lt;/p&gt;&#10;&lt;h3 id="one-api-typed-at-both-ends"&gt;One API, typed at both ends&#10;&lt;/h3&gt;&lt;p&gt;keryx and Scout.DM describe their API once, in OpenAPI, and generate both the Go server&amp;rsquo;s code and the browser&amp;rsquo;s TypeScript types from that one file. A check fails when the committed types fall behind it. krites documents its API by hand instead (&lt;a class="link" href="https://krites.phpboyscout.uk/reference/studio-api/" target="_blank" rel="noopener"&#10; &gt;studio API&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;None of the three adds behaviour of its own. krites requires that the studio &amp;ldquo;exposes nothing the CLI/engine can&amp;rsquo;t do&amp;rdquo;, and keryx runs the same scenarios through the studio and the command line and checks they leave the same files behind. &lt;a class="link" href="https://phpboyscout.uk/showcase/estate-architectural-patterns/#one-engine-several-faces" &gt;Estate architectural patterns&lt;/a&gt; covers that rule across the estate.&lt;/p&gt;&#10;&lt;h3 id="locked-down-by-where-it-listens"&gt;Locked down by where it listens&#10;&lt;/h3&gt;&lt;p&gt;krites&amp;rsquo; studio listens on the local machine only, and refuses any other address. keryx&amp;rsquo;s is open on loopback, and anywhere else it needs a one-time token that becomes an HttpOnly, SameSite=Strict cookie served over HTTPS (&lt;a class="link" href="https://gitlab.com/phpboyscout/keryx/-/wikis/specs/0018-studio-exposure-auth" target="_blank" rel="noopener"&#10; &gt;keryx spec 0018&lt;/a&gt;). It&amp;rsquo;s a cookie rather than a header because the browser&amp;rsquo;s image, audio and video requests can&amp;rsquo;t send a header. Scout.DM&amp;rsquo;s portal, which DMs reach from anywhere, signs people in with Discord and runs under a content security policy that allows only its own files, plus two inline items admitted by hash.&lt;/p&gt;&#10;&lt;h3 id="a-small-supply-chain"&gt;A small supply chain&#10;&lt;/h3&gt;&lt;p&gt;keryx and krites ship no runtime npm dependencies at all. krites wrote its own photo-grid virtualiser rather than take a library, because &amp;ldquo;a compromised JS dependency ships as malware inside an Apple-notarized app&amp;rdquo; (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/wikis/specs/0014-svelte-ui-refactor" target="_blank" rel="noopener"&#10; &gt;krites spec 0014&lt;/a&gt;). Development dependencies are pinned to exact versions, and cicd&amp;rsquo;s security component checks every UI for known vulnerabilities, risky code and package provenance.&lt;/p&gt;&#10;&lt;h3 id="tested-in-a-real-browser"&gt;Tested in a real browser&#10;&lt;/h3&gt;&lt;p&gt;Components are tested with Vitest and testing-library, and types with svelte-check. The two studios run Playwright against a mocked API, nine spec files each, and krites&amp;rsquo; include a run with a 4,000-frame shoot to prove the grid only ever draws a small window of it. Scout.DM&amp;rsquo;s portal is driven by a headless browser against the real Go server, sending its real policy header, and the run fails on any policy violation, which is how every SvelteKit or Vite upgrade gets checked.&lt;/p&gt;&#10;&lt;h2 id="decisions-and-what-they-cost"&gt;Decisions and what they cost&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Svelte.&lt;/strong&gt; The smallest runtime and the cleanest fit inside a Go binary. &lt;em&gt;What it cost:&lt;/em&gt; where a library would have been the quick answer, as with krites&amp;rsquo; photo grid, the estate writes its own to stay at zero runtime dependencies.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;SvelteKit, measured first.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; the portal builds to 172 KB against 56 KB for the plain version, and the server carries two hashes that break when SvelteKit moves a file, which it did in 3.0.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Generated, not committed.&lt;/strong&gt; keryx builds its UI as part of Go&amp;rsquo;s generate step rather than committing the built files. &lt;em&gt;What it cost:&lt;/em&gt; a release needs Node, and krites can&amp;rsquo;t be installed with &lt;code&gt;go install&lt;/code&gt;, which skips the step and embeds the placeholder.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Types from one file.&lt;/strong&gt; &lt;em&gt;What it cost:&lt;/em&gt; the generated types cover the shapes of requests and responses, not the URLs, so a mistyped path is still a runtime error. Closing that would need a library at runtime, &amp;ldquo;and the SPA ships zero runtime dependencies&amp;rdquo;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="proof-in-use"&gt;Proof in use&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;keryx, krites and Scout.DM, all on Svelte 5.57 and Vite 8, with Scout.DM&amp;rsquo;s portal on SvelteKit 3.&lt;/li&gt;&#10;&lt;li&gt;All three build, lint and test through cicd&amp;rsquo;s Svelte components, and keryx and krites run its security scan too.&lt;/li&gt;&#10;&lt;li&gt;Nine Playwright spec files in each studio, and a headless policy check on the portal.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="use-it-when-and-when-not-to"&gt;Use it when, and when not to&#10;&lt;/h2&gt;&lt;p&gt;Use it as a model if you ship a Go tool with a web UI and want it to stay one binary, one API and one set of behaviour. The placeholder-plus-release-guard trick works for any embedded front end, whatever the framework.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s built for UIs that sit in front of a tool, not for hosted web applications. All three are static builds with no server-side rendering, by design.&lt;/p&gt;&#10;&lt;h2 id="where-its-going"&gt;Where it&amp;rsquo;s going&#10;&lt;/h2&gt;&lt;p&gt;New web UIs start on SvelteKit, carrying over the embedding, the typed API and the browser checks from all three, with plain Svelte still there for a tool small enough not to need it.&lt;/p&gt;&#10;</description></item></channel></rss>