This blog's repository holds seventeen markdown files sitting next to the code. An architecture note, a setup guide, a deploy explainer, and the instructions I wrote for agents. I wrote all of them, most of them for myself. This morning one question stuck with me: are they still true?
The question looks harmless, but something uncomfortable sits underneath it. I'm no longer the only reader. CLAUDE.md and AGENTS.md are the first files an AI agent reads in every session. A human reader pauses — "hmm, this might be out of date." An agent doesn't pause; it takes the sentence I wrote as the law of its own repository and acts accordingly. Stale documentation used to be a courtesy problem; now it's a behaviour problem.
So I ran a scan. The number came out: of 146 path references, 37 pointed at no file at all. My first reaction was "so a quarter of my documentation has rotted." Going through all thirty-seven by hand, exactly three turned out to be stale — and those three weren't in the files I never open. They were in the files I open most.
Every number in this piece was measured on commit 6f9f6dff. I deliberately left the fix to a separate commit, so a reader can find the same numbers in the same tree.
The recipe (so you can recompute it)
The method is crude but repeatable. I take the tracked markdown files, pull out the backtick-quoted tokens, and check whether the ones that look like paths actually resolve to a file. Two limits up front: the scan only looks at .md files — the .mdx content files are out of scope — and it only treats a token as a candidate if it consists of letters, digits, dots, slashes, underscores and hyphens. Glob expressions like *.mp4 drop out at the first step.
Across seventeen docs there were 824 backtick tokens. Of those, 655 weren't paths and fell out through filters: 102 all-caps environment variables, 32 extension-only tokens (.mdx and friends), 25 npm package names (@astrojs/node), 17 git refs and repo names, 15 URL routes (/blog, /en), 11 system paths (/etc/...), 3 template placeholders (NNN, YYYY), and 450 that were globs, text with spaces, or plain prose.
That left 169 tokens; since the same reference shows up in more than one place, they collapsed into 146 unique "doc plus reference" pairs. For each one I looked, in this order: is the exact path tracked, does it resolve relative to the doc's own folder, does it match a directory prefix, and — if only a bare filename was given — is there exactly one file with that name in the repo. That last step is deliberately lenient: when a doc writes generate-content.ts, I accept that the file is scripts/generate-content.ts. 109 of the 146 resolved this way — 47 by exact path, 46 by filename, 11 by directory match, 5 by path suffix.
Along the way I fell into a measurement trap worth recording. To check whether the dist reference was in .gitignore, I ran git check-ignore dist and nothing came back. But I had written the rule as dist/, and git's own documentation says it plainly: a pattern ending with a separator matches only directories, not a file or a symbolic link of the same name. The directory wasn't on disk at that moment, so nothing matched; running git check-ignore dist/x showed the rule instantly.
The scanner's second flaw I only noticed as I was finishing this piece: I had forgotten to put .toml in my extension list. So wrangler.toml — sitting in backticks in BLUEPRINT.md, deleted on 28 September alongside deploy.yml — never entered the 146 at all. The scan both manufactures suspects and misses a real corpse. The number isn't a finding; it's the start of the path to one.
Thirty-seven suspects, seven separate charges
I reviewed the remaining 37 pairs by hand. For each one the question wasn't "where is the file?" but "why doesn't this reference resolve?" The answers fell into seven buckets, and each bucket calls for different work.
Scanner noise (4). Tokens like -en.png or .md/.mdx aren't paths, they're patterns. My filter missed them; those are the script's fault.
Examples and sample names (5). While explaining how video files should be named, remotion/SHORTS-PIPELINE.md gives copy-paste commands: mv ~/Downloads/output.mp4 remotion/public/me-veo/me-veo-002-microservices-tr.mp4. Then, under a heading that literally says "Examples," it lists sample names. No such clip exists on disk, and none needs to — the real clips are named after other topics. These five tokens don't point at a file, they illustrate a pattern. Putting them on the suspect list was my scanner's doing; the doc promises nothing here.
Paths created at run time (5). site.conf is produced by the setup script, dist is build output, and .wrangler is a precautionary ignore rule for a folder that may linger on older machines. All three are in .gitignore on purpose. They're absent from the repo because they're supposed to be.
On disk, invisible to git (4). The genuine references to the shorts videos' source clips: the remotion/public/me-veo directory, me-clips/me-idle.jpg, and the first VEO clip. The *.mp4 patterns are deliberately in .gitignore; I don't put tens of megabytes of video in the repo. I checked the main checkout on disk and all four are in place. Git's documentation is clear here: ignore rules apply to untracked files, while files already tracked are unaffected. My scan was looking in the wrong place, because it only interrogated the world git knows about.
Living outside the repo (2). While describing the origin firewall, BLUEPRINT.md mentions cf-lock.service. That's a systemd unit; it belongs in /etc/systemd/system/, not inside the repo. I asked the server: enabled and running. The second is update.sh — I'll come back to that one, because the person who had it filed as stale was me.
Marked as a plan (12). src/assets/brand/me/README.md lists ten character images: me-happy.png, me-thinking.png, and so on. None of them exist, neither in the repo nor on disk. But the doc listed them with the - [ ] box. In GitHub's markdown documentation that marker means exactly one thing: an incomplete item. Those lines don't say "this file exists," they say "I'm going to make this." Written five months ago, still honest. The same goes for share-to-youtube.ts.
Given a tombstone (2). deploy.yml and scripts/publish-site.mjs really were deleted — on 28 September 2026, when I removed the Cloudflare adapter. On line 93 of BLUEPRINT.md they stand like this: the name deploy.yml struck through, the deletion date and reason beside it, and the other two files named in the same sentence. The scanner says "no file"; the doc says "I know, I deleted it on 28 September."
And the ones that really were stale (3). Three references remained. All of them share one shape: a present-tense, undated, unmarked sentence whose underlying mechanism had long since been removed.
The one I thought was the fourth
Let me start with where I got it wrong, because that's where this measurement's real lesson lives.
On the first pass I counted four stale references. The fourth was update.sh on line 159 of BLUEPRINT.md: the old deploy path, run from the repo's deploy/ folder, pulling every minute and rebuilding and restarting the service when it saw a change. The file was deleted on 26 June and the doc still describes it. It looked like an open-and-shut case.
Reading the whole line changed that. The paragraph ends with a note: "the primary path is runner-push; the timer is currently off and kept as a backup." So the doc already says the mechanism has dropped to standby. On top of that, git blame shows the caveat was written on 9 June — seventeen days before the files were deleted. And when I checked the server, the script was sitting at /opt/mustafaerbay/deploy/update.sh, still executable. Deleting it from the repo hadn't deleted it from the machine.
So this reference belongs in the same bucket as cf-lock.service in my own taxonomy: an artefact living outside the repo, with a correct doc and a scanner looking in the wrong place. Not stale.
That didn't make me happy, because it came from two separate failures: the first time I read that line I cut it short, and the part I cut was exactly the sentence that proved me wrong. The number in my title was one too high because of a reading error.
Three stale lines
The first of the remaining three is content-self-chain.yml. BLUEPRINT.md describes this workflow as though it were alive, and even gives its cron expression: 15,45 4-20. The file was deleted on 9 September, in the cleanup that permanently fixed the mail flood. For forty days the doc explained a cron job to me that no longer existed.
Here's the detail that stings. That line is line 96 of BLUEPRINT.md — three lines below the tombstone I wrote on 28 September. And I went and looked at that commit's diff: the content-self-chain.yml line sits right there among the context lines of the tombstone change. The name of a file I had deleted nineteen days earlier was on my screen while I was recording the death of another.
The other two are a set: mustafaerbay-deploy.timer and mustafaerbay-deploy.service. Both were deleted on 26 June during the incremental build work — a hundred and fifteen days ago. deploy/README.md still lists the systemd units in a table, and rows two and three of that table are these two dead units: "git pull polling every minute," "rebuild and restart triggered on pull." No date beside the row, no caveat. BLUEPRINT.md added a note when it described the same mechanism; deploy/README.md didn't. That's the only difference between them, and the whole classification rests on it.
I won't call it carelessness; it's a question of where attention goes. I knew what I had deleted that day, so I recorded it. What I deleted in June and early September had already closed in my head, while it was still open in the doc. Documentation isn't a backup of my memory; it's a photograph that stays wherever my memory left it.
The finding I expected turned out wrong
I went into this with a hypothesis, and a confident one: rot accumulates in the docs I wrote once and never returned to. A file with a single commit is the suspect.
The data threw it back in my face:
| Document | Pairs | Unresolved | Stale | Commits |
|---|---|---|---|---|
BLUEPRINT.md |
49 | 8 | 1 | 4 |
deploy/README.md |
6 | 2 | 2 | 7 |
remotion/SHORTS-PIPELINE.md |
12 | 8 | 0 | 1 |
src/assets/brand/me/README.md |
12 | 10 | 0 | 1 |
AGENTS.md |
8 | 0 | 0 | 12 |
KURULUM.md |
10 | 0 | 0 | 4 |
The two single-commit docs produce half of all unresolved references, and not one stale line among them. Of the stale lines, one sits in a file with four commits and two in a file with seven. The maintained docs are the wrong ones; the neglected ones are right.
When I understood why, I laughed at myself a little. Writing the shorts files on 15 May, I didn't know what that day would turn out to mean, so I wrote carefully: a date at the top ("as of 15 May 2026"), pending items with - [ ], finished ones with a tick, waiting ones with an hourglass. That document hasn't been updated for 157 days and it's still honest, because not one of its sentences makes a claim about today. A dated photograph doesn't become a lie when it ages; it just becomes an old photograph.
The table in deploy/README.md, on the other hand, describes a live system in the present tense. No date, no marker, no owner. The only way such a sentence stays true is for someone to come along and fix it when the mechanism changes. For a hundred and fifteen days nobody came; "somebody" is me, and I was looking at other work.
I'm not claiming staleness isn't a sign of neglect. But the distribution in this repo says something specific: what carries a doc's honesty isn't the discipline of updating it, it's the tense of the sentence and the marker next to it. A checkbox, a date, a one-sentence caveat — all three are free, and all three lasted five months.
Documentation that gets consumed repairs itself
One more row in that table caught my eye. AGENTS.md carries eight path references and not one of them is in the unresolved bucket; with twelve commits, it's the most-edited doc in the repo. CLAUDE.md comes in with thirteen references, two unresolved, and both innocent.
Those two staying sound is less a triumph of discipline than a result of feedback. Both are read every session, and the reader does the wrong thing immediately when the information is wrong. An instruction with a bad path reports itself on first use; I fix it right then. That's where the twelve commits come from.
BLUEPRINT.md and deploy/README.md aren't consumed, only read. They serve whoever wants to remember the architecture — mostly me, six months from now. A wrong line stops nobody on the spot, so nobody fixes it. It's no accident that all three stale lines live there.
The rule I've taken from this is blunt but useful: I trust a document's accuracy based not on how many people read it, but on how many hours it takes for its errors to stop someone. A line that has stopped nobody for days is probably no longer true.
What the scan can never see
I ran one last check, because I had started to trust those 109 "healthy" references too much. The first row of the systemd table in deploy/README.md is mustafaerbay.service — Node SSR, port 3040. The file is in the repo, tracked; the scanner called it healthy without hesitation.
I asked the server. The unit is disabled and inactive. The site has been served from a container for months; the blog-serve container had been up for eleven hours when I ran the query. The table's first row passed the scan clean — because it points at a file that was never deleted — and still doesn't describe today's reality.
The same query threw in a bonus. mustafaerbay-deploy.timer, which I deleted from the repo on 26 June, is still sitting on the server inside /etc/systemd/system/, switched off. systemd's own documentation separates the states: disabled means the unit file exists, isn't enabled, and carries an [Install] section; not-found means there's no unit file at all. What came back to me was disabled. For the service beside it I got static — also present, but without an [Install] section, so it can't be enabled. Deleting something from the repo doesn't delete it from the server: the doc went on describing the dead, and the server went on keeping the dead.
The lesson here is the oldest one in measurement: existence and liveness are not the same thing. A file being there doesn't show the mechanism is alive. The soundness of a reference has to be measured with a runnable question — systemctl is-enabled, docker ps — not with ls. I fell into a similar trap counting external links: seven of my sources returned 404 and seventy-five had moved — there too, the first number hid the real problem.
The to-do list I came away with
Fixing three lines is ten minutes of work; I'm doing it in a separate commit, right after this article's. But those three lines aren't what the measurement told me. From here on I'm applying four rules to my own documentation:
-
Every sentence that describes a live system in the present tense gets either a date or a runnable command next to it. Instead of "git pull polling every minute," write "verify the state with
systemctl is-enabled ...". Let the reader — and the agent — test the claim themselves. - The commit that removes a mechanism also writes the doc's tombstone. Struck-through name, date, one sentence of reason. This is what architectural decision records have been doing for years: every ADR template carries a field for the record's own status, and the record is never deleted. I did it on 28 September and that line is still honest three weeks later.
-
I will never write unfinished work as a plain sentence. The checkbox is free and lasts five months. Twelve lines written with
- [ ]proved me wrong twelve times in this scan; in the good sense. - I won't trust a scan's output without interrogating the scanner. In this measurement, 34 of 37 suspects came out innocent — a false positive rate of ninety-two percent. Had I believed the script and declared the docs "rotted," I might have gone off deleting files that exist or "fixing" lines that were right.
The fourth rule also closes a question. What if I made this a CI gate — fail the build when a tracked doc references a path that doesn't exist? At ninety-two percent false positives every build would go red, and within a week I'd either disable the gate or write an exception list that silences the real findings along with the noise. This scan isn't a gate, it's a reading list: the automatable part is the scan, and the part that isn't is reading thirty-seven lines one at a time.
A note to myself as well: five days ago, in the same repo, I counted my scripts and went looking for their callers, and there my first count came out too low. Here it came out too high. Same tool, same repo, errors in opposite directions — that's exactly where my trust in the scanner needs calibrating. The script behind this piece isn't in the repo either; before I break my own rule again, it should sit next to the docs it reads.
Conclusion
For a long time I thought of documentation debt as "a file that wasn't updated." This measurement showed me the debt actually accrues in the tense. Every sentence written in the present, undated and unmarked, quietly starts charging interest the moment it's written; staying true requires someone to keep coming back. A dated photograph, a marked plan or a dated tombstone ages on its own.
For someone working alone in their own repo, that distinction looks like a small stylistic preference. With an agent on the reading side that no longer pauses, it isn't small: an agent can see "as of 15 May 2026" and act with caution, but it takes an undated table at face value. What saved my documentation over the past five months wasn't my discipline; it was a handful of square brackets I threw in, in a hurry, back in May.
Top comments (0)