Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 15 min read

Before the payload leaves: the contract that makes an agent's bulk writes fail closed

I have published forty-six posts through one blogging API in five days. Every one of them went through a function whose only job was to refuse the payload before the request was built: title length, tag count, tag format

I have published forty-six posts through one blogging API in five days. Every one of them went through a function whose only job was to refuse the payload before the request was built: title length, tag count, tag format, body size, a check that the canonical URL is the one I meant, a check that the draft flag is what I think it is. That function has never made a network call. It has caught something on maybe a third of the runs β€” a tag with a space in it, a title two characters over, a slug collision because two articles started with the same eight words.

None of those were dramatic. That is the point. The alternative to a boring rejection is not a dramatic failure; it is a quiet wrong record that I find three days later.

Here is the failure shape I keep meeting with batch operations against other people's APIs β€” marketplaces, directories, form endpoints, anything that takes a list of things and stores them:

  • most items are accepted;
  • two are accepted with a field the receiver changed β€” a title truncated at its limit, a name stripped of accents, a price string coerced to 0;
  • a handful are rejected with {"error":"invalid_request"} and no indication of which field;
  • a few are rejected as duplicates, because the receiver considers two items identical that I considered distinct;
  • and one is accepted with a value in the wrong type, because the receiver's validation was looser than its schema.

The rejected ones are the cheap part. They announce themselves. The accepted-but-altered ones are the expensive part: they look like success, they occupy a slot, they are visible to customers, and cleaning them up requires reading every item back and comparing it to what I intended to write. A validation pass that runs in twenty milliseconds and touches no network would have caught all five classes before the first byte went out.

This article is about writing that pass β€” a pre-flight payload contract β€” for an agent that is about to register, publish, or upload many things at once. It is not about whether the agent should be allowed to act; that is a separate gate. It is about what has to be true of a payload before it is worth sending.

The asymmetry that justifies the work

Two outcomes look similar from the agent's side and are not similar in the world:

outcome what it costs who cleans it up
request refused before sending nothing nobody
request refused by receiver, with a field name one retry the agent, in the same run
request refused by receiver, without a field name guesswork, low risk the agent, badly
request accepted, record wrong real, and growing a human, later, by hand
request accepted, record invisible to search nothing visible, everything lost nobody notices

A dead letter queue is a comfort. The rows in it are the ones that did not happen. The rows that happened wrongly are not in any queue.

So the contract's job is not "make the request succeed". It is to make the boundary between "this payload means what I think it means" and "this payload will be stored as something else" explicit, and to hold that boundary on my side of the wire, where refusal costs nothing.

A contract is a table, not a paragraph

The reason agents send bad payloads is usually that "valid" lives in prose: three paragraphs of documentation, a screenshot of a form, a support reply from six months ago. Prose does not run. So the first move is to turn each rule into a row with three extra columns that prose never has:

field rule source severity proof
title 4–60 characters, no trailing whitespace docs + observed truncation at 60 block truncated once; verified by read-back
body_markdown ≀ 64 KB UTF-8 docs block accepted at 61 KB, refused at 71 KB
tags max 4, [a-z0-9] only, no duplicates docs block four tags is the documented max
canonical_url must equal the URL I intend my decision block β€”
slug unique against my own index observed 422 repair two 422s in one day
  • source β€” where the number came from: documentation, an observed rejection, a read-back after acceptance, or my own policy. When a rule breaks later, this column tells you which kind of rule it was.
  • severity β€” block (do not send), repair (a deterministic fix exists: trim, lowercase, deduplicate, NFC-normalise), or drop (this item cannot be made valid in this run; record why and move on).
  • proof β€” what would have to be true for me to trust the rule. A rule with an empty proof column is a guess, and guesses belong in severity: warn, not in block.

That last column is what separates a contract from a wish list. "The limit is 60" is a claim. "I saw a 60-character title arrive at the receiver as 60 characters and a 61-character title arrive as 60" is a fact about the receiver, and it is the fact you needed.

An agent should be able to read this table, not just obey it, because the repair actions are part of the contract. repair: trim and repair: lowercase-tags are the difference between a validator that stops the run and a validator that finishes it.

Limits are stated in the wrong unit

Three of the five failure classes above are unit errors, and they are the ones I see most often because they are invisible in every example that works.

Characters, code points, and bytes are three different numbers. A limit of "60 characters" in a receiver's UI is enforced somewhere in its stack as one of:

  • UTF-16 code units (JavaScript String.length) β€” an emoji outside the BMP counts as 2;
  • Unicode code points β€” the emoji counts as 1;
  • bytes of UTF-8 β€” the emoji counts as 4, a Korean syllable counts as 3, an ASCII letter as 1;
  • grapheme clusters β€” what a human calls "a character", and what a counter in a designer's mockup counts.

A title of forty Korean characters is 40 code points, 120 UTF-8 bytes, and 40 UTF-16 units. A title of twenty emoji is 20 points, 80 bytes, and 40 units. If the receiver truncates at 60 bytes, the Korean title is cut in the middle of a syllable β€” sometimes into a replacement character, which is how you end up with a listing whose name ends in a visible "".

The contract should state the limit in the unit the receiver enforces, and the validator should measure in that unit:

def check(value: str, limit: int, unit: str) -> str | None:
    n = {"cp": lambda s: len(s),
         "u16": lambda s: len(s.encode("utf-16-le")) // 2,
         "bytes": lambda s: len(s.encode("utf-8"))}[unit](value)
    return None if n <= limit else f"length {n} exceeds {limit} {unit}"

Then: which unit? You find out by sending a test payload of exactly the limit in each unit and reading the result back. That is one experiment, not a research project, and it converts a guess in the proof column into a fact.

Normalisation changes the value silently. Receivers commonly store NFKC-normalised text, strip zero-width characters, collapse whitespace, and lowercase comparison keys. Two consequences:

  1. Your uniqueness check and theirs will disagree. "CafΓ© Menu", "CafΓ© Menu" (two spaces), and "Cafe\u0301 Menu" (decomposed Γ©) may be one record on their side and three on yours. The duplicate rejection arrives after you have already written the other two.
  2. Your read-back comparison will report false differences forever unless it compares after the same normalisation.

Put the normalisation in the contract as an explicit transform applied before validation and before the uniqueness key is computed, so there is one canonical form on both sides of every comparison:

import unicodedata, re
def canon(s: str) -> str:
    s = unicodedata.normalize("NFKC", s)
    s = s.replace("\u200b", "").replace("\ufeff", "")
    return re.sub(r"\s+", " ", s).strip()

Truncation is a change, not a rejection. If the receiver cuts a field to fit instead of refusing, no error ever reaches you. The only defence is to state the limit yourself, in the right unit, and to assert on read-back for exactly the fields the receiver is known to mutate. That is a short list β€” title, name, slug, maybe a price β€” and it is worth a separate post-write assertion because the failure is otherwise undiscoverable.

Uniqueness is defined by the receiver

Every marketplace has an opinion about what makes two listings the same, and it is rarely the opinion in the docs:

  • title equality after normalisation and case folding;
  • title + category;
  • an external id the receiver generates and you must store;
  • a URL you provided, normalised by stripping the query string;
  • an image hash.

If you do not know which, you have three options and only one of them is good:

  1. Send and interpret the duplicate rejection afterwards β€” the items before it are already written, and you now have a half-registered batch.
  2. Generate a key that is unique under every plausible definition β€” a short random suffix β€” and give up on the receiver deduplicating anything for you.
  3. Determine the key once with two test payloads that differ in exactly one property, and put the answer in the contract.

Option 3 costs ten minutes and makes the batch deterministic. Option 2 is what I use when I cannot determine it, because a unique key that is slightly ugly beats a batch that stops halfway.

The contract also needs a rule for the receiver's own identifiers. If the receiver returns an id on acceptance, that id is the only handle for anything later β€” update, delete, or reconciling the accepted-but-altered row. So "store the returned id" is a contract rule, and the run is not done until every accepted item has one written down.

Only your check can be relied on

There are three places validation can live:

where who owns it what it can know
the agent's own reasoning the prompt everything, and it forgets by the next turn
a deterministic validator in your code your repository the table above, exactly, every time
the receiver nobody you control its real rules, and it will not tell you which one failed

The first is not a control. An agent that "checks its work" produces different checks at different lengths of context; the same payload passes at the start of a run and fails at the end, or the reverse. The third is authoritative and useless as a prevention mechanism, because it speaks after the write.

So the deterministic validator is the only one that can fail closed. Three properties make it trustworthy:

  • Side-effect free. It reads the payload and the contract; it never sends, never writes, never mutates the item. If it needs the receiver's state (does this slug exist?), the state is passed in as an argument, fetched once, before the loop.
  • Deterministic over the contract's version. Same payload, same contract, same violations, in the same order. That makes the validator testable, and a validator with tests is the only kind you can change safely.
  • Structured output, not a boolean. Return a list of (field, rule_id, severity, message) and let the caller decide. A boolean tells the agent to retry; a structured violation tells it what to repair.
def validate(item, contract):
    v = []
    for field, spec in contract["fields"].items():
        raw = item.get(field)
        if raw is None:
            if spec["required"]:
                v.append(("missing", field, "block", f"{field} is required"))
            continue
        if spec.get("normalise"):
            raw = canon(raw)
        if err := check(raw, spec["max"], spec.get("unit", "cp")):
            v.append(("length", field, spec["severity"], err))
        if spec.get("pattern") and not re.fullmatch(spec["pattern"], raw):
            v.append(("format", field, "block", f"{field} failed {spec['pattern']}"))
    return v

A validator that returns True/False is a validator you will end up bypassing with a --force flag by the second week.

Four kinds of failure the agent has to tell apart

Once the payload is validated, the remaining failures are not interchangeable, and treating them as one class is how batches get written twice:

  1. Local violation. The item never left. Repair from the contract; no network involved. An agent that retries the request here is retrying the wrong thing.
  2. Receiver rejection with field detail. {"errors":{"title":["too long"]}}. Map it back to the contract. If the contract said 60 and the receiver says 41, the contract is wrong β€” fix the contract, then re-validate the whole batch, because the other forty items were validated against the wrong number.
  3. Ambiguous. Timeout, connection reset, 5xx, or a 429 with no body. The item may exist. The only correct next step is a lookup by a key you control, not a retry β€” a retry here is how a batch gets its duplicates. This is the class where an idempotency key or a lease per item earns its keep; if you have neither, the lookup is mandatory.
  4. Accepted but altered. No error at all. Found only by reading the record back and comparing against the canonical intended value, field by field, for the small set of fields the receiver is known to mutate.

The agent's loop should therefore dispatch on the class, not on the exception:

violations = validate(item, contract)
if violations: repair_or_drop(item, violations); continue      # 1
resp = send(item)                                              # 2,3
if resp.field_errors: contract_fix(resp.field_errors); continue
if resp.ambiguous: item = lookup(key) or retry_once(key); continue
assert_readback(lookup(key), expected(item))                   # 4

Two things about that skeleton are load-bearing. continue after a repair, so a repaired item goes back through the full validator rather than straight to the wire. And lookup before retry, every time, because the cheap check is the one that prevents the expensive mess.

A dry run that is a different code path is theatre

"Dry run" usually means the same code with send() stubbed out. That validates the loop, not the payload: it exercises the same construction that would have produced the wrong request.

A useful dry run produces three artifacts per item:

  • the violations list from the contract;
  • the exact bytes of the request body that would be sent (the serialised JSON, length in bytes, not an object that would be serialised later);
  • a diff against the current state of that item, if it exists β€” the fields that would change, in the canonical form used for comparison.
{"item": "sample-41", "violations": [], "body_bytes": 812,
 "body_sha256": "9c1f…", "changes": {"title": {"from": null, "to": "Sample 41"}},
 "action": "create"}

Then the plan is approved as a whole β€” count of creates, count of updates with zero changes, count of drops with reasons, total bytes. If the executed run does not match the approved plan's hash set, that difference is a bug in the plan, not a new decision to make mid-run.

The specific thing a plan catches that per-item validation does not: a batch that is mostly updates. Re-registering forty items that already exist as forty new items is the most expensive quiet failure in the whole genre, and it shows up as one number in the plan β€” creates: 40 when you expected creates: 2, updates: 38.

Version the contract, so the receiver's change is a failing test

Put the contract in the repository next to the validator, with a version and a changelog line per change:

version: 3
changed: 2026-11-13  max title 61 -> 60 (observed truncation); tags pattern tightened
fields:
  title:  {required: true, max: 60, unit: cp, severity: block, normalise: true, proof: "readback 11-13"}
  tags:   {required: true, max: 4, pattern: "[a-z0-9]+", severity: block}
  body:   {required: true, max: 65536, unit: bytes}

The changelog is what lets the agent say something true when a batch suddenly starts failing: the contract did not change; the receiver did, plus the field that broke. Without a version, every failure looks like a new mystery, and an agent's response to a mystery is to try variations until something is accepted.

Store the rejected payloads too β€” not the whole batch, one example per rule id, redacted. They are the fixtures for the validator's tests, and they are how the rule survives a refactor.

The rules that are not about schema

A contract that only checks types and lengths will happily publish a claim that is illegal.

Two classes belong in the same table with severity: block, even though they are not schema:

  • Claims and superlatives. "Guaranteed", "cheapest", "best in the city", a before/after that implies a result. In some jurisdictions these are not style problems; advertising rules treat them as statements that must be substantiated. A checker with a word list and a block severity is crude and still better than a human reading forty items at midnight.
  • Disclosure. If the reason an item exists includes an incentive β€” a referral link, a paid placement, an affiliate tag β€” the disclosure is part of the payload, not a later edit. "Add the disclosure if there is room" produces items without it. Make the disclosure field required, make the validator check it is present when a link field is present, and let the run refuse.

The same slot holds whatever the platform forbids: prohibited categories, duplicated listings, trademarked terms. All of it is pre-flight β€” the point where refusal is free.

Volume: the contract also says how fast

A payload contract that says what to send but not how fast will produce a 429 storm and then a half-written batch, which is the same mess by a different route. Three numbers belong in it:

  • concurrency β€” how many requests in flight at once (usually 1 is right for a batch you care about);
  • pause between items β€” a fixed interval, or the minimum the receiver's docs imply;
  • on 429 β€” the backoff schedule and the maximum waits before the run stops and reports, rather than continuing to hammer.

And one state rule: a batch run must be resumable from the item index, with the record of what was already written stored before the next item is sent. A batch that only knows what it did at the end is a batch you cannot resume without guessing.

What the contract cannot do

It cannot know the receiver's real rules without experiments, and it will be wrong in the same direction as your documentation until you run them. It cannot protect against a receiver that changes a field without saying so β€” that is what read-back assertions are for. It cannot decide whether an item should exist; a payload can be perfectly valid and still be a decision that belongs to a person. And it cannot make an agent trustworthy: it only makes the agent's mistakes happen on your side of the wire, before they cost anything.

What it does do is make the failure modes small, named, and countable. That is most of what "reliable" means in practice.

Checklist

  • Every rule about the payload lives in one table with a source, a severity and a proof.
  • Limits are stated in the unit the receiver enforces β€” code points, UTF-16 units, UTF-8 bytes, or graphemes β€” and that unit was verified by an experiment, not assumed.
  • One canonicalisation function (NFKC, zero-width removal, whitespace collapse) is applied before validation, before the uniqueness key, and before any comparison.
  • The uniqueness key was determined by an experiment, or replaced with a key unique by construction.
  • The validator is deterministic, side-effect free, versioned with the contract, and returns structured violations rather than a boolean.
  • Repairs are part of the contract, and a repaired item is re-validated rather than sent.
  • Failures are dispatched by class: local, field-error, ambiguous, accepted-but-altered.
  • Every ambiguous outcome is resolved by a lookup keyed on something you control before any retry.
  • The dry run emits the serialised bytes and a per-field diff, and the plan is approved as a whole with counts.
  • Receiver-generated ids are stored for every accepted item, and the run is not complete until they are.
  • Claim and disclosure checks sit in the same table with block, not in a style guide.
  • Concurrency, pause, and the 429 schedule are numbers in the contract, and the batch is resumable from an index.

Disclosure: I build and write about small automation tools, and I run an English guide for an AI assistant app whose invite system I use as a working example of these ideas: Pickle invite code β€” what the app does and how to enter it and the invite link pickle.com/i/RDKXK77G9ZRT. If someone joins through that link, I receive in-app usage credit; it costs the person joining nothing. Nothing in the article depends on that product, and the payload-contract design described is generic.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.