Engineering

I created three duplicate posts because the only record was a file I didn't read

When an API has no list endpoint, your local record is not a convenience. It is the only state there is.

MobileByteSensei 8 min read
practicesdocumentationincidents

TL;DR

Some actions cannot be undone and cannot be listed afterwards. For those, the record you write locally is not documentation — it is the only representation of remote state that exists.

I had that record. It was correct, it was committed, and it had been written faithfully for 244 posts. I did not read it before publishing, so I created three duplicate stories on a live publication.

The discipline is not "write it down". Everyone writes it down. The discipline is reading it as a precondition of the next call, and making the system refuse when you don't.

The case this post is about

Medium's API has exactly two post endpoints and both of them create:

POST /v1/users/{id}/posts          -> 201
POST /v1/publications/{id}/posts   -> 201
GET  /v1/users/{id}/posts          -> 405    there is no list
GET  /v1/posts/{id}                -> 404    there is no read
PUT  /v1/posts/{id}                -> 404    there is no update

Measured, not read from documentation — the API is formally retired, and its docs disagree with it in places.

That shape has a consequence most APIs let you ignore: you cannot ask the service what you have already made. You can create a post. You cannot enumerate posts, fetch one, or update one. If you want to know whether something exists, the only authority is whatever you wrote down when you made it.

So the publisher writes a ledger on every create:

  - slug: act-and-the-macos-gap
    title: "What act can reproduce, and what it silently cannot"
    first_published: 2026-10-07
    targets:
      - platform: medium
        post_id: e9a31dae9677
        publish_status: draft
        publication_id: c7d41f24be0f

244 of those files, one per post, written on every single create going back months. The record was never the problem.

The symptom

I was publishing a daily tranche of three posts. To pick them, I counted what had not been sent yet — by looking for medium_url in each post's frontmatter:

live on medium : 168
written unsent : 91

I picked three from the 91, checked their canonical URLs resolved, published them, and reported three posts created.

Later, a preflight check I had just written fired on 6 posts when I expected 78. That gap was the first thing that did not add up. Chasing it, I listed the account's actual drafts:

Six drafts on the account: three titles, each appearing twice, with the second of each pair created by the duplicate publish

Three titles, six posts. All three of "mine" already existed — and note where that listing came from. Not the repository, which was the thing that had misled me, but the account's own drafts page, scrolled to the bottom and read back.

Why the obvious fix failed

"Check whether it's published before publishing." That is precisely what I did. The check was does this post's frontmatter have a medium_url?, and for all three the answer was no.

It was no because the publisher deliberately does not write frontmatter. Its header says so, and gives a good reason: a framework rule forbids scripts mutating that layer, so it emits the ledger to a directory and leaves the fold to a human step. A post created as a Medium draft therefore leaves no mark in its own source file at all.

So medium_url does not mean "not created". It means "not created and then manually reconciled". Those are different sets, and the difference was 76 posts.

"The frontmatter and the ledger should agree." They should, and keeping them in sync is a real fix — but it is the slow one, and it was not true at the moment I needed it to be. Nothing had folded those 76.

"Surely creating twice just updates it." There is no update. The API's only verbs are creates. Running the publisher again on an existing post does not replace it; it makes another one. This is written, in plain English, in the header of a reconciler script sitting in the same directory:

"Re-running the publisher with --public does NOT promote those drafts; it creates a second, public copy of every one of them. With 100 drafts already sitting there that is 100 duplicate stories."

I read that file the day after it would have stopped me.

The actual mechanism

The ledger and the frontmatter answer two different questions, and I had been treating them as one.

The ledger answers: does this exist remotely? It is written at the moment of the irreversible act, by the code performing it. It is the only local artifact with that property.

The frontmatter medium_url answers: do we know its public address? That is a later, softer fact. It requires the post to be not just created but published by a human and then reconciled — two steps that may never happen, and had not happened for 76 posts.

I used the second to answer the first. The sets differed by 76, so the answer was wrong 76 times, and I happened to draw three of them.

The deeper property is what made it unrecoverable rather than merely wrong. For most APIs this mistake self-heals: you re-run, the service 409s or upserts, and nothing is lost. Here:

  • there is no list, so the mistake is invisible from the service side
  • there is no update, so the second call cannot reconcile with the first
  • there is no delete in the API either, so cleanup is a manual browser action

No list, no update, no undo. Each alone is survivable. Together they mean the local record is load-bearing for correctness, not for convenience — and a load-bearing record that nothing requires you to read is decoration.

Every post endpoint on the API measured rather than read from documentation: two that create and return two hundred and one, and list, read and update all absent — so no request can ask the service what has already been made

The fix

Make reading it a precondition the code enforces, in the same place the irreversible call is made:

// CREATE IS NOT IDEMPOTENT AND MEDIUM HAS NO LIST ENDPOINT. `GET /v1/users/{id}/posts` is
// 405, so the ONLY local record that a create happened is the ledger this script writes to
// .publish-out/ — and because the script deliberately does not touch frontmatter, a post
// created as a DRAFT leaves no mark in post.md at all.
const ledgerPath = `.publish-out/${slugForLedger}.ledger.yaml`;
let priorLedger = "";
try { priorLedger = Deno.readTextFileSync(ledgerPath); } catch { /* never created */ }
const priorId = /^\s*post_id:\s*(\S+)/m.exec(priorLedger)?.[1];

if (priorId && !args.includes("--allow-duplicate")) {
  console.error(
    `BLOCKED: "${slugForLedger}" was already created on Medium as post ${priorId}.\n\n` +
    `  Medium's API cannot update a post, so publishing again would create a SECOND story\n` +
    `  rather than replacing the first.\n\n` +
    `  If it is a draft, publish it from Medium's interface — that is the human step.\n` +
    `  --allow-duplicate overrides this, and means exactly what it says.`,
  );
  Deno.exit(1);
}

Four properties that matter more than the code:

It sits in the publisher, not in a linter. The check has to be where the irreversible call is, because that is the only place it cannot be forgotten.

It names the existing id. BLOCKED: already created as post e9a31dae9677 is actionable; "already published" sends someone hunting.

It says what to do instead. The real next step for a created draft is a human publishing it in Medium's interface — the one thing the API genuinely cannot do.

The override is ugly on purpose. --allow-duplicate means what it says. A flag named --force would get typed reflexively.

The separate, slower fix was reconciliation: a script that harvests the publication feed and writes real URLs back into frontmatter. It resolved 49 posts that were already live and had never been folded, and reported 17 unmatched rather than guessing at them. The corpus went from a believed 168 live / 91 unsent to a measured 217 live / 27 draft / 15 never created.

How we proved it

The gate was tested by watching it fail, in both directions.

RED — a post whose ledger records a create:

$ deno task publish posts/act-and-the-macos-gap/post.md --live
BLOCKED: "act-and-the-macos-gap" was already created on Medium as post e9a31dae9677.

GREEN — a post with no ledger. It passed this gate and was then stopped by an entirely different one, an asset-pin check complaining about an unresolvable image. That is the better GREEN result: it shows the new gate let the call through on its own terms rather than everything being blocked by something upstream.

Then the counts, re-derived from the ledgers rather than from frontmatter, and cross-checked against the account's own drafts page — because a local count that agrees with itself is not evidence.

The ledger write ordered before every cosmetic follow-up, because a create whose record did not land is indistinguishable from one that never happened on a service with no list endpoint

What this generalizes to

The question to ask of any system you write to: if this call succeeds and my process dies immediately after, how would anything ever find out?

If the answer involves asking the service, you are fine — the service is the record, and you can reconcile whenever you like. If the answer is "the row I was about to write", you are in the territory this post is about, and three rules apply.

Write the record as the very next statement. Not at the end of the batch, not in a finally. The window between the remote create and the local record is where permanent duplicates are born, and it should be as close to zero as the language allows.

Read it as a precondition, in code. This is the half people skip, and it is the half that matters. Writing a record nobody is required to consult produces exactly the state I was in: perfect documentation of an event, and a system cheerfully repeating it.

Make the duplicate-create path refuse by default. Not warn. The cost is asymmetric — a blocked legitimate publish costs one flag; an unblocked duplicate costs manual cleanup on a live surface, and for genuinely irreversible operations it cannot be cleaned up at all.

There is a fourth, which is less of a rule than an observation. The knowledge that would have prevented this was already in the repository, in the header comment of a file in the same directory, stated clearly enough that I quoted it verbatim above. The record was written. It was accurate. It was even eloquent.

Nothing made me read it. That is the entire failure, and no amount of better writing fixes it — only a gate does.

Keep reading

This is part of 100 practices of senior engineers — the habits that separate five years of experience from one year five times. Follow MobileByteSensei for the rest as they are written.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS