100 technical posts, and the gates that stopped them being slop
Publishing at volume with an AI agent is easy. Publishing at volume without shipping slop needs machinery that refuses, and refuses loudly.
A hundred and one technical posts. A hundred and forty thousand words. Every one drafted with an AI agent in the loop.
That is the least interesting fact about the project, and it is the only one anybody asks about. The interesting part is the machinery that sat between the agent and the publish button, because that machinery is the entire difference between a corpus and a content farm.
The case this post is about
One draft, run through twelve refusal classes, blocked by four of them:
posts/unlocking-compose/post.md BLOCKED
[block] canonical:missing — no canonical_url — syndicating would donate the SEO
[block] voice:ai-tell — banned phrasing: "delve into"
[block] voice:ai-tell — banned phrasing: "game-changer"
[block] image:relative-assets — fig-one.png breaks off-site
[warn] length:below-target — 1,043 words — clears the floor, misses the band
Five findings in one pass. Not one of them is a judgement about whether the post is any good, and every one of them would otherwise have been caught by a human reading carefully, or not at all.
This post follows that one draft, because the two voice:ai-tell lines are a substring match with
no cleverness in them whatsoever — and that is deliberate, since a detector you can argue with is a
suggestion.
The symptom: volume is trivially easy now
Ask any competent model for a thousand words on Modifier.shadow and you will get a thousand
words on Modifier.shadow. They will be fluent, correctly structured, and superficially
plausible. They will also contain a confident claim about the default shape parameter that is
wrong, and you will not notice, because the prose does not sound uncertain when it is wrong.
This is the actual problem with AI-assisted writing at volume. Not that the output is bad — it is that the output is uniformly good-looking, so your ability to spot the bad parts by reading degrades exactly as the volume that requires it goes up.
Why "just review everything carefully" fails
The obvious fix is human review. Read every post before it ships.
That works for five posts. At a hundred it fails for a reason that has nothing to do with diligence: reviewing prose for factual wrongness is the same activity as writing it, and costs about the same. If review is the only gate, the ceiling on publishing is the ceiling on writing, and the agent has bought you nothing but a first draft you now have to verify line by line.
Worse, reviewing is unreliable in a specific direction. A human reading their tenth post of the day pattern-matches on shape. Correct-looking structure reads as correct. The failure mode of tired review is precisely the failure mode of the generator.
The mechanism: a refusal is worth more than a suggestion
What worked was moving the check out of the reading and into a program that exits non-zero.
The publishing pipeline for these posts runs twelve refusal classes before a post can be marked ready. Not suggestions in a style guide — a gate that blocks:
canonical:missing no canonical_url — syndicating would donate the SEO
evidence:no-image a claim with no artifact backing it
frontmatter:invalid schema violation
image:relative-assets an asset path that breaks off-site
length:min under the hard floor
length:max over 3,500 words
length:below-target clears the floor, misses the 1,200–2,500 band
links:dead a link that 404s
links:unreachable a link that times out
links:deferred a link left as a placeholder
tags:max more than five tags
voice:ai-tell banned phrasing

Twelve gates. Most of them are shut for any given post, and that is the point — the ones that stay open are the ones a piece has already satisfied.
The last one is the one people ask about, so here it is in full:
delve into · in today's fast-paced · it's worth noting that · let's dive in
game-changer · unlock the power · in this article, we will · buckle up
the world of · revolutionize · seamlessly integrate · it is important to note
Twelve phrases. Not a style opinion — a fingerprint. Each one is a phrase that appears far more often in generated text than in writing by someone who has done the thing they are describing. Banning them does not make generated prose good. It makes the generic register unavailable, and a model denied the generic register has to reach for something specific or produce nothing.
That is the whole trick. The gate does not detect slop. It removes the vocabulary slop is written in.
The implementation is unremarkable and that is the argument — twelve classes, one severity each, and a run that reports every finding rather than the first:
type Severity = "block" | "warn";
type Finding = { rule: string; severity: Severity; detail: string };
const BANNED = [
"delve into", "in today's fast-paced", "it's worth noting that", "let's dive in",
"game-changer", "unlock the power", "in this article, we will", "buckle up",
"the world of", "revolutionize", "seamlessly integrate", "it is important to note",
];
function check(post: Post): Finding[] {
const f: Finding[] = [];
const prose = stripCodeFences(post.body);
const words = prose.split(/\s+/).filter(Boolean).length;
if (!post.meta.canonical_url) {
// Syndicating without a canonical makes the copy the original. A warning here
// would be read as a formatting nit; it is an irreversible SEO decision.
f.push({ rule: "canonical:missing", severity: "block", detail: "no canonical_url — syndicating would donate the SEO" });
}
if ((post.meta.tags ?? []).length > 5) {
f.push({ rule: "tags:max", severity: "block", detail: `${post.meta.tags.length} tags, maximum is 5` });
}
if (words < 800) f.push({ rule: "length:min", severity: "block", detail: `${words} words below the hard floor` });
else if (words > 3_500) f.push({ rule: "length:max", severity: "block", detail: `${words} words over the ceiling` });
else if (words < 1_200 || words > 2_500) {
f.push({ rule: "length:below-target", severity: "warn", detail: `${words} words — clears the floor, misses the band` });
}
for (const phrase of BANNED) {
// Case-insensitive, substring. Not clever, and a clever version would be
// arguable — which is the property a refusal cannot afford.
if (prose.toLowerCase().includes(phrase)) {
f.push({ rule: "voice:ai-tell", severity: "block", detail: `banned phrasing: "${phrase}"` });
}
}
for (const img of imageRefs(post.body)) {
if (!img.startsWith("/")) {
f.push({ rule: "image:relative-assets", severity: "block", detail: `${img} breaks off-site` });
}
}
return f;
}
const verdict = (f: Finding[]) =>
f.some((x) => x.severity === "block") ? "BLOCKED" : f.length ? "warn" : "pass";
One draft through it:
posts/unlocking-compose/post.md BLOCKED
[block] canonical:missing — no canonical_url — syndicating would donate the SEO
[block] voice:ai-tell — banned phrasing: "delve into"
[block] voice:ai-tell — banned phrasing: "game-changer"
[block] image:relative-assets — fig-one.png breaks off-site
[warn] length:below-target — 1,043 words — clears the floor, misses the band
Five findings in one pass, which is the only shape that works at volume — a gate that exits on the
first means five runs to find five problems, and by run three the author is fixing symptoms rather
than reading. And the two voice:ai-tell lines are a substring match with no judgement in them at
all. That is deliberate: a cleverer detector is a detector somebody can argue with, and a refusal you
can argue with is a suggestion.
Why the interesting gates are the boring ones
voice:ai-tell gets the attention. links:dead did more work.
An agent writing about an API will cite documentation. It will cite documentation that does not
exist, at a URL shaped exactly like one that does, with total confidence. No amount of careful
reading catches this, because the URL looks right — that is the failure mode. A HEAD request
catches all of it in about a second.
Same for evidence:no-image. A post claiming a visual bug needs a picture of the visual bug. The
gate does not check that the picture is good; it checks that a claim about something visible is
accompanied by the visible thing. That one constraint is why the posts contain measurements
rather than assertions — the cheapest way to satisfy it is to go and actually produce the artifact.
How to prove it: the gate that could not fail
A gate you have not tried to defeat is a decoration, and I have the receipt for that.
Building a check recently, I wrote the usual pair of fixtures: a RED one that should fail, a GREEN one that should pass. RED passed. The fixture's own explanatory comment — the one saying "this file deliberately omits the phrase X" — contained the phrase X, so the grep matched the comment and the check reported success.
That gate had been protecting nothing, and it would have gone on reporting green forever. Every check in the pipeline now ships with a fixture that is proven to fail, because a canary that cannot fail proves nothing at all.
The same week, a different check told me the entire corpus had a mean SEO signal of 0.68 out of 4
— a catastrophic number that would have justified a large rewrite. The number was wrong. I had
measured against the wrong keyword: the post's tag rather than the words in its slug, so a post
titled "Brush and gradients" scored zero for not containing the word graphics. Corrected, the
real figure was 2.42. A measurement you have not tried to break is an opinion with a decimal point.
What the gates do not catch
Being honest about the ceiling matters more than the list, because the list is the part people copy.
None of these checks can tell whether a post is worth reading. links:dead proves a URL
resolves, not that it supports the sentence citing it. evidence:no-image proves a picture
exists, not that it shows what the caption claims. voice:ai-tell proves twelve phrases are
absent, and a determined generator will produce something equally hollow in a vocabulary I have
not thought to ban.
They catch a specific, narrow class: the errors that are invisible on the page and obvious to a program. A dead link looks identical to a live one. A missing canonical is invisible by definition. A tag over the limit reads fine and is rejected by the API. That class turns out to be where most of the damage lives at volume, which is why the machinery pays — but it is a class, not a guarantee.
The judgement about whether a post has a point still sits with a person, every time. The gates just make sure that person is spending their attention on the thing only they can do.
What this generalizes to: taste does not scale, refusals do
None of this is specific to writing. The shape applies anywhere an agent produces work faster than a human can evaluate it:
Put the check in a program, not a prompt. "Please don't hallucinate URLs" is a suggestion a
model is free to ignore under pressure. A HEAD request is not.
Ban the register, not the content. You cannot write a rule for "is this insightful". You can write a rule that refuses the stock opener about the accelerating modern world, and the second one closes off most of the space the first one was worried about.
I know that rule works, because it caught this post. Writing the paragraph above, I typed the opener out in full as an example — outside a code fence, in ordinary prose — and the gate flagged it. It was right to. The sentence you are reading is the rewrite.
Make the cheap path the correct path. Every gate above is easier to satisfy honestly than to work around. That is a design property, not an accident — a gate that is annoying to satisfy honestly teaches people to route around it.
Test the test. Half of my gates were fine. The ones that were not had never been asked to fail.
The volume was never the achievement. A model will produce a hundred posts in an afternoon, and they will be worthless. The achievement is a hundred posts that a stranger can act on — and that came from twelve programs that say no.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".