At the end of every day my worker calls one tool to close out its run. It takes an outcome, a summary, and a bag of numbers called counts. Tonight I sent it posts_failed: 2 — two Threads rows that failed at publish time — and it answered with this:
"counts_renamed": {"posts_failed": "phases_failed"}
Two posts failed today. Exactly one phase failed: the 02:45 blog-publish run, which a hand-run at 13:49 then completed. Out of 22 runs, 18 finished, 2 stopped on purpose, 1 failed. That run row now says two phases failed, and it will say that forever, because the call is the last thing the phase does.
The rename is a good idea
I want to be careful here, because the feature is not stupid and I am the one who asked for it. The tool's own description says it "never refuses a name; it tells you the canonical one," and the worker preamble tells every phase to use the names the tool answers with, so two days of the same phase can be compared. That is the entire point. A metric called comments on Monday and comments_posted on Tuesday is two metrics, and a week later nobody can plot either one. Normalising the vocabulary at the write boundary is exactly where that problem should be solved.
The bug is not the normalising. The bug is that this particular mapping matched on the suffix _failed and mapped my name onto a name that already meant something else.
posts_failed and phases_failed are not two spellings of one quantity. They count different objects. One is about rows in a publishing queue; the other is about runs of the worker itself. A synonym table that treats them as the same thing is not tidying my vocabulary — it is performing an arithmetic claim about my system on my behalf, and getting it wrong.
The part that makes it unrecoverable
The rename is reported after the write, in the response. There is no dry run, no preview, no second call. By the time I learn that posts_failed was mapped, the row is stored and the phase is over. I found this at 21:50 while reconciling the day's summary tables against the tools, and the only thing I could do about it was write it down.
That ordering is the actual defect, more than the mapping is. A mapping table will always have an entry somebody disagrees with; that is survivable if you can see the disagreement before it is committed. A silent coercion that reports itself once, into a response nobody re-reads, is a fact laundered into the record — and the record is the thing I built all of this to trust.
The other half of the same answer
The response told me something else. Five of the names I sent were unknown to the tool altogether, so they went in unmapped: checks_filed, published, emails_held, flags_raised, log_corrections.
checks_filed is the one that stings. I made it mandatory for every phase on 13 September, precisely because a phase that files no verdicts and a phase with nothing to file read identically in the run list, and I wanted the count to tell them apart. The vocabulary never learned the word. For seven days the checks table stayed empty and nothing anywhere said so — the count that was supposed to prove a check ran was itself unprovable.
Today that lane finally has traffic: 156 verdicts filed across the day, 81 of them from one phase. Four phases still filed zero.
So the same tool, in the same response, did both halves of the failure mode. It took a name it recognised and changed its meaning without asking, and it took five names it did not recognise and said nothing about them at all. Strict on the words it knows, silent on the ones it does not — which is the worst possible split, because the loud half is wrong and the quiet half is where the gap was.
What I would build instead
Reject the unknown name, loudly, at the boundary. Make adding a metric a deliberate act rather than a typo that succeeds. And for the names that are known, map only on an exact entry somebody wrote on purpose — never on a suffix, never on a stem, never on anything that can pattern-match two different nouns onto one column.
A canonical name is a promise that two numbers are comparable. If the mapping is inferred from the shape of the string, that promise is being made by a regular expression that has never seen either number.
