The Silent No-Op: When Your CMS Writes to a File the Renderer Never Reads
I edited a post, saved it, watched the deploy finish, and the page did not change. Nothing errored. Nothing warned me. The write succeeded and the read path ignored it, and that silence is the whole bug.
View companion repoI edited a post, saved it, watched the deploy pipeline turn green, and reloaded the page. Nothing changed. Not a stale cache, not a CDN lag. The exact bytes I had just written sat in post.md, on disk, in the repo, in the deployed commit, and the rendered page showed the old paragraph. No error. No warning in the build log. The write had every appearance of having worked, because it had worked, in the narrow sense that the file on disk was correct. It just wasn't the file the page read from.
That gap between "the write succeeded" and "the write mattered" is the subject of this post, and it's more common than it sounds once you go looking for it. Any system with two representations of the same content, a source-of-truth file and a rendered or cached derivative, has a silent-failure mode where the source updates and the derivative doesn't, and nothing in the system is obligated to notice. I found this one in my own blog's render path, in a project called WithAgents, and the fix taught me something I now apply everywhere I have a write path and a read path that can drift apart.
Two render paths, one silent winner
The blog's post page has two ways to render a post body. One reads the markdown file directly and runs it through a converter. The other reads a hand-authored Block[] array, a typed, structured representation of the same content, and renders each block with a dedicated component. Both paths exist in the same function, site/src/app/posts/[slug]/page.tsx:468-482:
const typedBody = getPostBody(slug);
const prefix = prefixForSlug(slug);
if (typedBody) {
return (
<div className="wa-typed-body">
{typedBody.map((block, i) => (
<BodyBlock key={i} block={block} accent="var(--accent)" prefix={prefix} />
))}
</div>
);
}
return (
<MarkdownProse html={markdownToHtml(post.content)} prefix={prefix} />
);
getPostBody(slug) is checked first. If it returns anything, the typed block renderer wins outright and markdownToHtml(post.content), the function that would have read my edit, never executes. The markdown path is a fallback, reached only when a slug has no typed body registered. I confirmed this isn't an edge case by counting: site/src/lib/post-bodies/ has forty-three files, post-01.ts through post-43.ts, one per published post except the newest. Forty-three of forty-four live posts render through the typed path. The markdown file is load-bearing for exactly one of them.
The intent behind this split is documented, not accidental. site/src/lib/post-bodies/index.ts:1-4 states the contract in a header comment:
// Slug → typed body lookup. If a post slug returns a Block[], the
// [slug]/page.tsx renderer uses BodyBlock instead of falling back to
// markdownToHtml. Posts not listed here render via the existing markdown
// pipeline unchanged.
The lookup function itself, at lines 101 to 103, is three lines:
export function getPostBody(slug: string): Block[] | null {
return BODIES[slug] ?? null;
}
There's no ambiguity in the code. If BODIES[slug] exists, that's the render. post.md's body content, for those forty-three posts, is dead weight: present, syntactically valid, and never converted to HTML for a real reader.
Why the fallback was right, and why it stopped being right
When this design went in, it was a good decision. Block[] is a real type. The union covers roughly fifty variants (paragraphs, headers, pull quotes, seven chart kinds, four diagram kinds, and a set of custom WithAgents primitives like failure lists, gate diagrams, and economics tables) that plain markdown has no syntax for. Writing a post with a chart or a role-comparison table meant hand-authoring a typed structure that the markdown pipeline couldn't produce. The fallback made sense: most posts don't need custom blocks, so most posts should just render their markdown, and the handful that do need charts get a typed override. Two paths, cleanly separated by need.
The trap wasn't the design. It was scale plus habit. Once I started routinely converting posts to typed bodies, whether they strictly needed a chart or not, because the typed renderer also gives finer control over pull quotes and code block styling, the exception became the rule, and I kept editing post.md out of muscle memory. post.md is where the frontmatter lives, where the word count gets checked, where every editorial gate in this pipeline runs its lint. It is the file every tool treats as the source. It is not, for forty-three out of forty-four posts, the file the browser renders. A fix I made to a factual error, a code sample, a paragraph transition, any edit to post.md body content on a typed post, passes every check, deploys cleanly, and changes nothing a reader sees. The system reported success at every layer because every layer did its job correctly. The job itself was aimed at the wrong output.
This is the shape of a silent no-op: not a crash, not a validation failure, not even an incorrect value. It's an entirely correct write to a file that has been quietly disconnected from the thing it used to control.
Detecting it structurally, not by vibes
The instinct, once you know both files exist, is to diff them: does post.md's content match post-NN.ts's content? That check fails immediately, and not because something is broken. The two files are supposed to differ. post-NN.ts is a typed, block-structured re-authoring of the markdown, not a re-serialization of it. It has diagram nodes, chart data, and pull-quote formatting that plain markdown can't express and never will. Content equality between them was never true on day one, so a diff-based check produces a permanent false positive on every typed post in the repo. That's a check nobody trusts, so nobody runs it, so it stops existing within a release cycle.
The check that actually catches the failure mode measures staleness, not equality: compare file modification times. If post-NN.ts exists for a slug and posts/post-NN-*/post.md has a newer mtime than it, the markdown source has moved since the typed body was last touched. Someone edited the file that isn't rendered and never propagated the change to the file that is. That's the exact failure I hit. The site-sync-gate skill implements this as one of its publish checks and states the reasoning directly:
"The rendered body is the typed
Block[]filesite/src/lib/post-bodies/post-NN.ts, NOTpost.md... Content equality is the wrong comparison, the two files differ by construction for every typed post, so the gate measures staleness by mtime: ifpost-NN.tsexists andposts/post-NN/post.mdis newer than it, the typed body is behind the source and the gate flags it."
The check is overridable, correctly. Sometimes a post-NN.ts refactor is deliberately mid-flight and the markdown is legitimately ahead of it for a commit or two. But overridable-with-a-named-reason is a completely different posture from silent. An override requires someone to type a justification into a gate report. A silent no-op requires nobody to notice at all. The difference between those two states is the entire value of the check.
The general rule for dual-representation systems
Generalize past this one render function and the pattern shows up everywhere two representations of the same content coexist with an implicit priority between them. A cache layer that serves stale content while the origin has already updated is the same shape: the write to the origin succeeded, the read path (the cache) didn't notice, and nothing failed loudly enough to tell you. A feature flag that's still gating code nobody remembers is the same shape: someone "removed" the feature by deleting the call site, but the flag definition and its dashboard toggle still exist, silently controlling nothing. Shadow configuration, an env var that used to matter, read by a code path that got refactored out from under it, is the same shape again. In every case, a system component keeps accepting writes and reporting success, while a different component has quietly stopped consuming them.
The unifying fix is the same in every case, and it's not "keep the two representations equal." That's often impossible or actively wrong, as it is here. The fix is: make the write path's authority explicit, and instrument the gap between "content changed" and "authoritative path consumed the change" as a first-class, checkable signal. A staleness check beats a content-equality check whenever the two representations are allowed to diverge in shape but not in recency. It's a narrower claim, "these two things were touched in the right order" instead of "these two things are the same," but it's the claim that's actually true, and a check that asserts something true is a check people leave enabled.
A write path that fails is annoying but honest. You see the error, you fix it, you move on. A write path that quietly stops mattering is worse, because every signal in the system tells you it worked. The file saved. The build passed. The deploy shipped. The only thing that would have told me otherwise was a gate specifically built to compare the wrong thing on purpose: not whether two files agree, but whether the one nobody reads got touched more recently than the one that does.
Continue the series
- 44SeriesTwenty-Six Agents Died in Three Waves: Probe Before You Fan OutThree dispatch failure modes — a dead model route, a schema-valid parameter the route rejects, and agents that stall while reporting running — and the cheap probe that catches all three before you commit the fleet.
- 46SeriesObservability You Can See But Not Keep: A Live Stream Is Not a RecordA dashboard that renders a run in real time convinces you observability is solved. The real test is whether you can answer a question about a run that finished yesterday — and on my agent platform, the honest answer was zero rows.
- 43SeriesThe Agent Cannot Edit Its Own Answer Key: Structural Guardrails Against Reward HackingAn agent scored against ground-truth files has three shortcuts to a fake PASS: edit the answer key, tune a constant until green, or assert "verified" without running anything. I closed each one in the harness, where the model cannot rationalize past it.
- 47SeriesThe Capability LinkedIn Never Shipped: Designing a Pipeline Around One Irreducible PasteLinkedIn's API can fire feed posts on its own. It cannot create a Pulse article. I stopped trying to route around that gap and built the human paste into the pipeline as a first-class, gated state instead.