AI-Driven Development

Design for the API call you cannot take back

An API with create but no delete changes where your guards live. Four checks that run before the request, and the one that only exists because a create returns 201 on the wrong target.

MobileByteSensei 7 min read
A one-way gate with every check stacked in front of it and nothing behind
aiagentsguardrailsapi

Most integration code is written on an assumption nobody states: if this goes wrong, I can undo it. Remove the undo and the shape of correct code changes, because every check has to happen before the request rather than after the result.

The case this post is about

Four requests, under a minute, against a publishing API:

POST   /v1/publications/{id}/posts   201    create works
PUT    /v1/posts/{id}                404    no update
DELETE /v1/posts/{id}                404    no delete
GET    /v1/users/{id}/posts          405    cannot even list what you made

Create-only. Every post created is permanent, unreadable back, and unlistable — so there is no reconciliation step, no cleanup, and no way to ask the service what exists.

This post follows those four status codes, because they move every guard to before the network call. With no undo, the request is the commit point, and a check that runs after it is a check that reports damage.

The symptom

An API I depend on supports create and nothing else. The endpoints exist in the docs; they answer like this:

POST   /v1/publications/{id}/posts   201    create works
PUT    /v1/posts/{id}                404    no update
DELETE /v1/posts/{id}                404    no delete
GET    /v1/users/{id}/posts          405    cannot even list what you made

A duplicate created by accident is permanent until a human removes it by hand in a web UI. There is no compensating transaction, no cleanup job, no retry that converges. The only safe number of wrong creates is zero.

That is not an exotic situation. Payment captures, outbound email, published posts, provisioned infrastructure with a manual teardown — plenty of things are cheap to do and expensive to undo.

Why the obvious fix failed

The obvious fix is to verify after: create, read it back, and if it is wrong, clean up. Every part of that assumes a GET and a DELETE. Here the read-back returns 405 and the cleanup 404.

The second attempt is idempotency keys, and the API does not offer them. You can approximate one locally, and that approximation is the whole design — but it only works if the local record is written before anything is believed, which is a discipline rather than a feature.

The failure I actually shipped was subtler than either. A create returned 201. It was attached to the wrong target — a personal profile instead of a publication — because one field was absent from the payload. Nothing in the response said so. The success path and the wrong-target path are byte-identical from the caller's side, and the local record dutifully wrote down a success.

A guard that runs after the response cannot see that. The response is fine.

The actual mechanism

With no undo, the network call is the commit point, and everything meaningful has to happen before it. Four checks, each of which caught something real.

1. The local record is the idempotency key. Before any create, a per-item ledger file is the duplicate guard:

if (await exists(`.publish-out/${slug}.ledger.yaml`)) {
  console.error(`${slug} already has a ledger — it exists remotely. Refusing.`);
  Deno.exit(1);
}

And immediately after a create returns, the ledger is written before anything else happens — before logging, before the next item, before any cosmetic follow-up step. A create whose ledger did not land is indistinguishable from one that never happened, and the next run creates a twin.

2. Required fields are refusals, not defaults. The wrong-target bug came from a field being absent and the code treating absent as "use the default":

const pub = post.meta.publication;
if (pub === undefined) {
  console.error(`no 'publication:' — refusing. Without it this lands on the personal profile.`);
  Deno.exit(1);
}

A deliberately personal post sets it to an explicit empty string. The distinction is between chosen and forgotten, and only a refusal can tell them apart.

3. Every external reference resolves first. The API fetches images server-side at create time and rehosts them permanently. A URL that 404s in that moment is baked in as a broken image forever:

for (const u of imageUrls) {
  const ok = await fetch(u, { method: "HEAD" }).then((r) => r.ok).catch(() => false);
  if (!ok) bad.push(u);
}
if (bad.length) { /* refuse, name them, exit 1 */ }

This one had already failed silently. A pinned asset commit was stale, the image URL returned 404, and the create went through and returned 201 with no image.

4. Batches are bounded, enumerated, and serial. Not "never more than one" — that is too strict to be useful — but never unbounded and never parallel:

enumerate candidates → print the exact list → refuse if over the cap
→ walk them one at a time → confirm each ledger before the next
→ stop on first failure, report what was done

The cap is the consent mechanism. Running the command consents to a bounded batch; it does not consent to an unbounded one, and the difference is what stands between a bad match and ninety permanent duplicates.

Composed, the four guards are one function that runs before the first network call and reports every problem rather than the first one:

type Candidate = { slug: string; publication?: string; imageUrls: string[] };

async function preflight(cands: Candidate[], cap: number) {
  if (cands.length > cap) {
    throw new Error(`${cands.length} candidates exceeds the cap of ${cap} — name them explicitly`);
  }

  const refusals: string[] = [];
  for (const c of cands) {
    // 1. the ledger IS the idempotency key
    if (await exists(`.publish-out/${c.slug}.ledger.yaml`)) {
      refusals.push(`${c.slug}: ledger exists — already created remotely`);
    }
    // 2. absent is NOT a default. An explicit "" is a choice; undefined is a forget.
    if (c.publication === undefined) {
      refusals.push(`${c.slug}: no 'publication:' — would land on the personal profile`);
    }
    // 3. the server rehosts at create time, so a 404 now is permanent
    for (const u of c.imageUrls) {
      const ok = await fetch(u, { method: "HEAD" }).then((r) => r.ok).catch(() => false);
      if (!ok) refusals.push(`${c.slug}: ${u} does not resolve — would bake in a broken image`);
    }
  }

  // Report EVERY refusal. Exiting on the first means N runs to find N problems, and
  // every run is one mistake away from a permanent create.
  if (refusals.length) throw new Error(`refusing ${refusals.length} problem(s):\n  ` + refusals.join("\n  "));
  return cands;   // 4. caller walks these serially, ledger-confirming each
}

The batch that was about to run when this was written:

$ publish --all --cap 12
refusing 3 problem(s):
  three-phases: ledger exists — already created remotely
  warm-context-codegen: no 'publication:' — would land on the personal profile
  reading-the-405: https://blogs.example.com/posts/reading-the-405/fig-probe.jpg
                   does not resolve — would bake in a broken image

Nine of the twelve were fine and none of them were created, which is the right behaviour for a batch whose failures are permanent. Note the third line in particular: that image 404 had already happened once and the create returned 201. The API's success response says nothing about whether the images in the body resolved, so there is no post-hoc check that would have caught it — the only place that check can live is before the request.

How we proved it

Each guard was tested against the failure that motivated it, which is the only test that means anything.

The image check, with the stale pin and with the fixed one:

images:  0/2 reachable   →  ❌ refusing to create
images:  2/2 reachable   →  ✅ attached: YES (publicationId=c7d41f24be0f)

The publication refusal, against the post that shipped wrong:

❌ this post has no `publication:` — refusing.
   Without it this lands on the personal profile, not the publication.

The ledger guard, by running the same create twice:

already has a ledger at .publish-out/<slug>.ledger.yaml — it exists remotely. Refusing.

The cost of getting there was three wrong creates, each requiring a manual deletion. That is the honest number, and it is also the argument: three manual cleanups bought four guards that make the fourth mistake impossible.

Where the guards are still thin

Three honest gaps, because a list of guards that sounds complete is its own kind of lie.

Partial batches. The serial walk stops on first failure and reports what was done, which is correct but leaves you in a split state: some created, some not. Resuming means trusting the ledger, and the ledger is only as good as the write that happened between the create and the crash. There is a window — small, real — where a create succeeded and its ledger did not.

The guards check shape, not meaning. Every one of the four verifies something structural: a field is present, a URL resolves, a count is under a cap. None of them can tell you the content is right. A post with the wrong body, correctly attached, passes all four.

A refusal is only as good as its trigger. The publication check fires on undefined. A field set to an empty string sails through, which is deliberate — that is the opt-out — but it means a typo'd YAML key that silently produces an empty value looks identical to a considered choice. I do not have a good answer for that one yet.

None of these argue against the guards. They argue against the feeling of safety that arrives once you have written a few, which is exactly when you stop looking.

What this generalizes to

Reversibility is a property you should look up rather than assume, because it decides where your checks belong. When undo exists, verify after and repair. When it does not, every check moves ahead of the call, "required" becomes a refusal rather than a default, and the local record has to be durable before the result is believed — because the remote system will not tell you twice.

One post a week, no digest

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

Follow by RSS