I keep a copy of every post from this blog on dev.to, the developer blogging platform. Each copy points back here with a canonical link, so search engines treat this site as the original. Last month I rewrote the descriptions on all of them — the short summary that shows on share cards and in dev.to's own search.
Forty-five articles. A job for a script, not a morning of clicking.
I did it the careful way. I updated one article first, and before and after I fingerprinted everything the update could plausibly disturb: the length of the body, the tags, the canonical URL, the title, the published state. Only the description had changed. Everything else was byte-identical. Then I ran the other forty-four.
updated 44, failed 0
Every request came back 200 OK.
Out of habit rather than suspicion, I ran the checker again — the one that compares what's live against what I intended. Twenty articles still had their old description.
Not a cache
My first thought was lag. The endpoint that lists all your articles at once is exactly the kind of thing that serves a slightly stale snapshot. So I skipped it and fetched each of the twenty individually, from the endpoint that returns a single article fresh.
Old descriptions, all twenty. The writes hadn't been delayed. They hadn't happened.
So forty-four requests had gone out, all forty-four had been answered with success, and twenty of them had changed nothing. Nothing in the responses separated the twenty from the twenty-four. Same status, same shape, same body echoing back.
The twenty had something in common
Laid side by side, the difference was in the article bodies. Every one of the twenty began like this:
---
title: "Zero Is Not a Measurement"
published: true
description: "The old summary, still sitting here."
tags: debugging, monitoring
canonical_url: https://tedagentic.com/posts/zero-is-not-a-measurement
---
The article itself starts here...
That block between the two --- lines is front matter: metadata written into the top of a markdown file, the same convention static site generators use. dev.to supports it. You can publish an article by sending the whole thing, metadata block included, and dev.to reads its settings from there.
The twenty-five articles that updated correctly had no such block. Their bodies were just the article.
Same author, same months, interleaved through the calendar. I had two kinds of article and had never known it, because from the outside — on the page, in the list, in the API — they looked identical.
And the one I had tested first, so carefully, with all those fingerprints? It was one of the twenty-five. My probe had been perfectly representative of the articles where the method works, and had told me nothing at all about the ones where it doesn't.
I was writing to the output of a function
Here is what happened on each of those twenty saves.
The request arrived with a new description. It was well-formed, the field was a legitimate part of the article, and dev.to accepted it. Then, as part of the same save, dev.to read the front matter out of the body, found a description: line there, and set the article's description from that. My value lasted exactly as long as it took to be overwritten by the block it was supposed to replace.
For those twenty articles, the description wasn't a field I could set. It was a computed value — read out of the body every time the article saved. I had been writing to the output of a function.
That is why the response said 200. The request was valid and it was processed. The write landed and was undone inside a single operation, and the status code described the first half.
Four of the twenty were stranger still. Their front matter had no description: line at all. For those, dev.to had been generating the description from the opening words of the article. They had never had a description as data in the first place — only a summary produced on demand, which no update to the field was ever going to reach.
A copy can be fixed. A derived value can't.
I've written before about copies of data drifting apart: a fact corrected in the database that never reached the page, prose that went on contradicting a dataset after the dataset was fixed. Those are real problems, but they share a comforting property. A copy exists. It is a stored thing that can be found, compared and corrected.
A derived value has no independent existence. There is nothing in the field to fix, because the field is not where the value lives. Overwrite it and you have written to a view, and a view is rebuilt from its source whenever the system feels like it. The only place to change it is upstream.
That is a different and worse failure than drift, because every check at the level of the field passes. You wrote it. The system confirmed it. For a moment, it was even true.
Changing the source
So the fix was to stop writing to the field and write to the source instead: edit the description: line inside each article's front matter, then send the whole body back. For the four with no such line, insert one.
That meant pushing the entire body of twenty live articles, not one field. A mistake there could damage far more than a summary, so every edit went through two gates. The new body had to differ from the old by exactly one line — one changed, or for the four, one added — or the edit was rejected before it was sent. And after each save I fetched the article fresh and confirmed two things: the description was now the new one, and the tags were exactly as they had been.
All twenty passed. The checker now reads forty-five of forty-five.
(One aside, for anyone scripting against the same API: dev.to answers Python's default urllib user agent with 403 Forbidden Bots. Send an ordinary user agent string and it works.)
You can't tell from the schema
The part that stays with me is that nothing warned me. The description was listed in the API as a property of the article. It accepted writes. It returned 200. There was no flag marking it as derived for some articles and stored for others, and I suspect there often isn't, anywhere. Whether a field is data or a projection of other data is usually an implementation detail, invisible until you write to it and watch it snap back.
So there are only two defences I trust now.
Read back from the source, not from the response. The response tells you the request was accepted. A fresh fetch of the thing, from the endpoint that doesn't cache, tells you whether your value survived. Those are different questions, and the success of the first says nothing about the second.
A probe only proves its own kind. One careful test validated my method for exactly the population that test article came from. If there are two kinds of thing hiding behind one interface, a single sample will happily vouch for the easy one. You find out there were two kinds when the second one fails — which is a strong argument for checking every result afterwards, rather than trusting a representative one beforehand.
The API never lied to me. It said it had accepted my request, and it had. I was the one who heard "it's done."
Top comments (0)