2026-09-05 · typescript · api · softwaredevelopment · cli
A Default That Is Safe on Create Is Destructive on Update
I wanted to add links to nine already-published articles. Just a bulk update through my own publishing tool.
The tool tried to unpublish them.
The safe direction of a default flips with the operation
The tool was built like this:
const publish = rest.includes("--publish");
const article = {
title: fm.title,
body_markdown: body.trim(),
published: publish, // decided by an explicit flag, not by frontmatter
tags: fm.tags ?? [],
};
published does not come from the article's frontmatter. It comes from --publish on
the command line. Without the flag, you get a draft.
The reason was right there in a comment:
Draft by default. Publish only when
--publishis explicit, because failing to publish is safer than publishing by accident.
For creation, that is correct. Shipping a half-written draft to the world costs more than forgetting to ship a finished one.
For updates, the same default points the other way.
Forget --publish while updating an existing article and you send published: false.
A live article drops back to draft. The URL stays; it just 404s.
- Forgot to publish → notice later, publish then
- Un-published by accident → every inbound link and every bit of search equity dies
The same phrase, "err on the safe side", named opposite directions depending on the verb.
The fix was not "infer it more cleverly"
My first instinct was to preserve the existing state on update: if --publish is absent,
keep whatever the article currently is.
I dropped it. You cannot distinguish "I meant to unpublish this" from "I forgot the flag." Inferring past something you cannot distinguish means that one day someone genuinely wants to unpublish and is silently ignored.
So it refuses:
// published comes from the explicit flag, so an update without --publish drags a
// live article back to draft. Accidental unpublishing is far more common than
// deliberate, so refuse. The URL survives but 404s, and the backlinks die with it.
if (existing?.published && !publish) {
throw new Error(
`"${fm.title}" is already published. Updating without --publish reverts it to a draft.\n` +
" Pass --publish to update it.",
);
}
I ran it without the flag and confirmed it exits 1.
If you want to unpublish, that should be its own operation. Never something that happens as a side effect of updating.
And then: identity was keyed on a value that changes
The same tool had a second hole — how it found the existing article.
// update if an article with the same title exists, otherwise create
const mine = await call("/articles/me/all?per_page=100");
const existing = mine.find((a: any) => a.title === fm.title);
It matched on the title.
The same day, I fixed a bug in the frontmatter parser. It stripped the outer quotes from
title: "Treating \"It Worked\" as ..." without unescaping what was inside, so
an article had shipped with backslashes in its name.
// before
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
// after
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1).replace(/\\(["\\])/g, "$1");
}
The moment that landed, fm.title produced a different string. And the published
article still carried the old one.
So find misses. It concludes there is no existing article. It falls through to create.
Had I pushed without noticing, a second copy of the same article would have gone live.
Fixing one bug is what exposed the other
This is not "I hit two bugs." It is one bug whose fix activated the other.
Identity was keyed on a derived value that the tool itself computes. Change how it is computed and the value changes. Change the value and identity breaks.
The same shape shows up everywhere:
- Keying on a normalised name → fixing the normalisation rules orphans your existing rows
- Keying on a hash of the body → reformatting makes it a different object
- Keying on a display string → correcting a typo in it is an identity change
Never key identity on a value you might fix.
The fix: record the id the other side assigned
id is assigned by the API. Nothing I do changes it. So the tool now records the id of
every article it creates.
{
"_comment": "filename -> article id. Titles change; identity must not.",
"ids": {
"composite-github-action": 4576296,
"linter-false-positives": 4577641
}
}
const knownId = store.ids[slug];
let existing = knownId ? mine.find((a) => a.id === knownId) : undefined;
if (knownId && !existing) {
throw new Error(
`id=${knownId} (${slug}) is not on the server.\n` +
" Either the article was deleted, or this is a different account's key.\n" +
" Continuing would create a duplicate, so stopping here.",
);
}
If there is a recorded id and it is missing, stop. Silently falling through to create is the single worst behaviour available.
Verifying it
I deliberately changed a title and pushed:
title: "... (Without npm Publishing) (id lookup test)"
-> PUT /articles/4576296
PUT /articles/4576296, not POST /articles. The title no longer matches and it
still resolves to the same article. The article count did not change.
Why it was keyed on the title in the first place
Because I did not want a state file. I wanted the articles to be nothing but Markdown.
I understand the impulse, and the price of it was inferring identity on every run. That works right up until the material the inference is built on changes.
Designs that hold no state tend to become designs that infer state instead. What you are inferring in place of storing is worth knowing explicitly.
Summary
- A default's safe direction flips with the operation. "Don't publish" is the safe side on create and means "unpublish" on update
- Do not infer past an intent you cannot distinguish. Refuse, and make it explicit
- Destructive operations get their own verb. Never as a side effect of another one
- Do not key identity on a value you computed — fixing the computation breaks identity
- Record the id the other side assigned, and stop when a recorded id goes missing
- Stateless designs tend to become inference-based designs. Know what you are inferring
Related
- Bisection Narrowed It Correctly and Still Never Reached the Answer
- Treating "It Worked" as Verification Ships Code That Only Works With One Commit
- What You Refuse to Check Decides the Quality of a Linter
I publish the configuration for splitting Claude Code into separate personas —
Architect, Coder, Reviewer, Conflict Resolver — under MIT. Copy it, run
./setup.sh, and it works. It does not depend on your tech stack.
https://github.com/quintetkit/quartet
I built one real tool using nothing but this workflow. Every Issue, PR, review and merge is still there. The parts that went wrong were not deleted.
https://github.com/quintetkit/mdlinkcheck
The version that adds a UI Designer persona, review criteria, a per-Issue parallel execution script and a 10-chapter guide is on the product page.
The full kit — five personas, the scripts and the complete guide in English and Japanese — is on BOOTH, a Japanese store with an English interface that takes international cards.
https://quartet-dev.booth.pm/items/8807156
The workflow itself is available
Quartet, the four-persona version, is published free under MIT. Quintet adds a UI Designer persona, review criteria, a per-Issue parallel execution script, and a 10-chapter guide.
See the free version Product page