Refuse, don't warn
8 October 2026 · Guardrails & fail-closed · the engineering team
The story
We publish engineering design documents from a private repository to a public site. The documents are written for the people building the system, so they are full of things that must not leave the building: ticket numbers, file paths, the names of internal services, placeholder text that a template left behind. From its first day the publisher refused rather than warned: any check that finds a problem stops the build with a short code, the file and line, and one sentence saying what it found.
That posture was right, and it still was not enough. When we reviewed the first documents we wanted to publish, we found text the checks did not cover: ticket numbers from families the pattern list had never heard of, repository paths inside code spans, and summary cards that were derived from the document after the checks had run and so had never been checked at all. None of this had leaked, because no page had gone out yet. All of it would have, because every one of those gaps was silent in exactly the way a warning is silent: it had nothing to say.
The lesson was not that refusing was wrong. It was that a check you have not watched fail is a check you do not have. We went back and gave every class of leak a name, a red case and a test, and we added one last pass over the finished page data, so that nothing added after the checks could escape them.
The pattern
Three habits turned that posture into something we trust.
Every check has a name, and the names are a closed list. Our publisher has a short, closed list of refusal codes, and the list lives in one file. Adding a code is a visible change that a reviewer sees. A reader of the build output learns the vocabulary quickly, and a code is easier to search for than a sentence that someone rephrased last month. A closed list also stops the slow drift where each new check invents its own way of complaining.
Every check starts red. Before we write a check, we write the smallest document that should make it fire and we put that document in the test fixtures under the code's name. The test asserts that running the publisher over that fixture produces exactly that code and nothing else. Only then do we write the check. If we skipped this, we would have checks that look right and never fire, which is worse than no check, because it buys confidence that is not there.
When a check is unsure, it refuses. A pattern that catches internal ticket numbers will also catch the name of a hash standard or a character encoding. We accept that. A false refusal costs an author one reworded sentence, and the message tells them which one. A missed leak costs a public page. Those costs are not symmetric, so the check is not symmetric either. Where a term is genuinely public, it goes on a small allow-list with a test per entry, so the exception is as deliberate as the rule.
The same rule reaches past the text checks. A document section the publisher was not told to show is dropped. A link to a document that is not public is a refusal, not a dead link. A part of a document nobody decided about is a refusal, not a guess. The question the publisher asks is never "is this probably fine" but "was I told this is fine".
One more thing fell out of this that we did not plan. Because the refusals are plain data, we could build a second mode that runs the same checks over every document and reports them as findings instead of failures. Authors use it to see what would block a document before they ask for it to be published. The public build and the preview run the same code, so the preview is never optimistic.
Try it
Pick one check in your pipeline that currently warns. Before changing it, write the smallest input that should trip it and add a test asserting that the pipeline fails on that input with a specific, named outcome. Watch the test fail. Then change the check to stop the build, with the name in the message. Keep a single file that lists every such name.
When you are tempted to add a way to bypass a refusal, add an allow-list entry with a test instead. The exception then lives in code, with a reason, where the next person can find it.