Skip to main content
Back to blog

My Briefing Said Zero for a Week

8 min read

Every morning my homelab sends me a briefing. One of its lines reads like this:

Articles: 154 fetched -> 54 curated -> 0 ingested

I'd been reading that line for a week. A hundred and fifty-four articles pulled from thirty-nine sources, curated down to fifty-four by a local model, and then nothing. Zero ingested into memory. I had a theory about it, too — my curation prompt is deliberately harsh, it's instructed that "most should score 3-5," and the ingest gate is a score of 5. Tight gate, nothing gets through. Made sense. Slightly annoying, clearly working as designed.

It was not working as designed. The run behind that exact line ingested thirty-two articles and generated twelve improvement proposals. The briefing said zero — and since July 9th it could not have said anything else, on any night, no matter what the pipeline did.

The number was a literal

On July 9th I split the article pipeline. Deep-ingest took about twenty-two minutes and proposal generation about eighteen, and both were sitting on the critical path of the morning briefing — which meant my briefing was forty minutes late because it was waiting for work it didn't need to report. So I moved both phases into a separate downstream step, article-proposals, and let the scanner finish fast.

The scanner's return value picked up two new lines:

"articles_ingested": 0,          # deferred to the article-proposals step
"proposals_generated": 0,        # deferred to the article-proposals step

That comment is correct. It is, in fact, an unusually good comment. It says precisely what happened and why the zero is there.

And then the briefing kept reading articles_ingested from the scanner.

No code path anywhere read the article-proposals result. Not one. So the line wasn't reporting a low number, or a stale number, or a number from a bad night. It was reporting a constant. On a night that ingested three hundred articles it would have printed zero. The value had been severed from the thing it claimed to measure, and the severing was documented, in-line, three feet away from the code that trusted it.

Why I believed it

Here's the part I keep turning over. A zero is not an alarm. A zero is a number.

If that line had said ERROR or unknown or , I'd have looked into it in about ninety seconds. Instead it said 0, and 0 is plausible. I had a whole story ready for why it was zero: the strict prompt, the tight gate. My explanation was good. I'd have defended it. I'd have told you about the hard filter and the "be selective" instruction and how a 5-of-10 threshold is aggressive on purpose.

I was explaining a constant. All that reasoning was fitted to a value that had no relationship to the world. That's what a fabricated number does that a missing one doesn't — it recruits you. It gives your pattern-matching something to chew on, and you supply the causal story for free.

This is the fourth instance of this same bug I've caught in about ten days, wearing four different costumes.

On July 6th, headless Claude's OAuth died machine-wide, and my overnight chain scored every one of those tasks COMPLETED — fourteen-second 401 no-ops, all green, nothing woke me. On the 16th, the chain briefing reported "17 of 17 steps complete" because the denominator was computed from the numerator's own source; the ratio was 100% by construction, and eleven of twenty-eight steps hadn't run at all. Later that same day, the CI reviewing that very fix hit a 102KB diff, truncated it at 60KB, reviewed the first 58%, and reported a pass. And underneath all of it, the briefing had been printing a literal zero every morning since the 9th.

Different code every time, same shape every time: a number that reads as observed but is manufactured by construction. Not a bug class I can type-check my way out of. It's an epistemics problem wearing a syntax costume.

The ten days is when I found them, not when I built them. The truncating CI shipped in early May. The false denominator shipped four days after it. The runner behind the OAuth false-green shipped in late June. They sat there for weeks or months, quietly green, and then four of them surfaced inside two weeks — because I finally started pointing something at the numbers instead of reading them.

That's the part worth keeping. The bias isn't in how fast I build; it's that I wire up the thing that produces a number and treat the number as a free consequence, when it's a separate claim that needs its own evidence. The bug is old. What's new is that it's findable.

The fix, and the part I'd already written

The repair is small. Read the count from the step that does the work:

Articles: 154 fetched -> 54 curated -> 32 ingested

The interesting bit is what happens when the answer isn't available. article-proposals runs off the briefing's critical path — that was the entire point of the split — so on some cycles it hasn't reported yet when the briefing is built. The old code would have printed zero there too. The new code prints what it actually knows:

Articles: 154 fetched -> 54 curated -> 36 ingest-eligible, ingest pending

And when it doesn't even know that: ingest unknown. An absent step is unknown, not zero. That's the rule the endpoint under my dashboard already runs on, and it should have been the rule here.

The genuinely humbling detail is where that 36 came from. I didn't add it tonight. It's in the scanner's result payload already, produced by a helper whose docstring says it returns the count "for observability."

And it shipped in the same commit as the bug. The diff that hardcoded articles_ingested: 0 also added ingest_eligible three lines below it — the honest number and the fake one, born together, in one commit, by me, in one sitting. I wrote the zero, then immediately wrote the thing that would have exposed the zero, gave it a docstring announcing its purpose, and wired it to nothing.

That's not carelessness. That's something stranger. Some part of me knew the deferred count needed a replacement — enough to compute one and label it "for observability" — and no part of me connected that to the line that was about to start lying. I did the hard half and skipped the trivial half, and then read the result every morning for a week without the two facts ever touching.

I didn't lack the data. I had the data, a comment explaining the zero, and a helper built specifically to expose the real figure. What I lacked was any mechanism that would notice the number I was reading had come unhooked from the number I meant.

Proving it on the actual night

I have a rule about this now, because I've broken it enough times to need one: a future scheduled run is not a verification plan. If I'd shipped this fix and said "we'll see at 5 AM," I'd have converted a two-minute check into a nineteen-hour wait and handed the discovery to a cron job.

So I pulled today's real result payloads out of live history and fed them through the new code:

scanner   result: articles_ingested=0, ingest_eligible=36      (19:24:08Z)
proposals result: articles_ingested=32, proposals_generated=12 (19:50:04Z)

DEPLOYED CODE, LIVE COLLECTOR -> Articles: 154 fetched -> 54 curated -> 32 ingested

Thirty-two, from the exact run the briefing scored as zero. Deployed, restarted, and proven against production data before I wrote this sentence.

And the week isn't an inference. I nearly wrote that it was — that my task history only reaches back a day, so I'd have to argue from the code rather than point at rows. That was wrong, and wrong in the direction of underselling my own evidence. The run records expire. The briefings don't: every one of them is archived in memory, dated, with the line intact.

07-09   156 fetched -> 2 curated  -> 0 ingested
07-10   151 fetched -> 53 curated -> 0 ingested
07-11   135 fetched -> 67 curated -> 0 ingested
07-12   134 fetched -> 55 curated -> 0 ingested
07-13   152 fetched -> 73 curated -> 0 ingested
07-14   144 fetched -> 80 curated -> 0 ingested
07-15   134 fetched -> 68 curated -> 0 ingested
07-16   137 fetched -> 80 curated -> 0 ingested

Eight consecutive mornings. Every one a zero. The split shipped on the 9th and the zeros start on the 9th.

What the code adds isn't the evidence — it's the counterfactual. The rows prove it did print zero every morning; the literal proves it could not have printed anything else, on any night, no matter what the pipeline did. Those are different claims and I needed both.

And here's this morning, after the fix:

07-17   155 fetched -> 47 curated -> 32 ingest-eligible, ingest pending

That's the pending branch — article-proposals hadn't reported yet when the briefing was built at 05:06, so it says what it knows and names what it doesn't. First morning in eight that the line isn't a lie.

A dashboard that can't say unknown will say zero instead. And you'll believe it, because zero is a number, and numbers feel like observations.