DEV Community

kongkong
kongkong

Posted on

Add Agent Writes Behind a Schema Digest, Not a Hopeful Prompt

A user clicks Export on the weekly revenue report and waits for a CSV that never arrives. The agent renamed amount_cents to amount because a UI mock spoke dollars, while the API still serialized integer cents. The cached client still posted to /reports/export, and the worker still selected a column that no longer existed. Where did that request die first: the button handler, the cached contract, or the database row?

I do not treat that mismatch as a prompting issue anymore, and I do not think you should either. An agent that assumes last week's contract is not being helpful; it is writing through a subway map from a closed station. Cheap generation makes those assumptions cheaper to print, so the blast radius grows every time someone says "just let the model fix it." My position is blunt: fail closed on schema drift before any model receives write authority.

The first layer that actually fails is almost never the model, even when the dashboard blames the prompt. It is the handshake you skipped between the UI, the API, and the migration that already landed. If the agent can POST without proving it saw the current schema digest, you handed it write paths across the codebase. Why would a retry help here, if that retry still carries yesterday's field names into the worker?

The opinion, without the hedge

I think the phrase "the agent will notice" is not an architecture, no matter how confident the demo looked. Noticing is a vibe, and production needs a digest, an If-Match header, and a 412. You can wrap tools and even ask the model to recite the OpenAPI file from memory. It will still assume a column that marketing renamed on Thursday, because recitation is not a handshake.

The honest response to an assumed write is not a clever repair; it is a rejected request that names the contract it missed. This is the same lesson cache people already learned in brownfield SPAs, just wearing an agent costume. A generated client that still believes v3 of a report shape is a stale cache, even if no CDN is involved. If you would not let a browser replay a PUT with a stale ETag, why allow a tool loop to do it?

A working handshake you can run

I want a vertical slice you can boot, not a diagram that pretends to be an implementation. The user action is POST /reports/export, and that is the only story I will keep on the table. The contract lives at GET /_contract/reports, returns a JSON Schema plus a hex digest, and every write must echo that digest. Auth is a scoped token, not a global admin key the agent found in an example README.

Persistence means the digest is computed from the same schema file the migration already used. So the database and the HTTP surface cannot drift in silence, which is the whole point of pinning. Here is a small Python module you can drop next to your API, labeled as a sketch you should run locally. It is not a claim about a fleet I secretly operate, and you should treat the numbers as fixtures.

# schema_digest.py
from __future__ import annotations

import hashlib
import json
from pathlib import Path
from typing import Any

SCHEMA_PATH = Path(__file__).parent / "contracts" / "reports.export.v4.json"


def load_schema() -> dict[str, Any]:
    return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))


def digest_for(schema: dict[str, Any]) -> str:
    canonical = json.dumps(schema, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def current_digest() -> str:
    return digest_for(load_schema())
Enter fullscreen mode Exit fullscreen mode

The contract file is boring on purpose, because boring is what you want a write path to be. Additional properties are closed, required fields are explicit, and cents stay cents instead of a hopeful float. If your agent cannot fill this shape, it is not ready to export anything that finance will trust.

{
  "$id": "https://example.local/contracts/reports.export.v4.json",
  "type": "object",
  "additionalProperties": false,
  "required": ["range_start", "range_end", "amount_cents", "currency"],
  "properties": {
    "range_start": { "type": "string", "format": "date" },
    "range_end": { "type": "string", "format": "date" },
    "amount_cents": { "type": "integer", "minimum": 0 },
    "currency": { "type": "string", "enum": ["USD", "EUR"] }
  }
}
Enter fullscreen mode Exit fullscreen mode

The API then publishes that digest and refuses writes that do not match, before the ORM session even opens. I like Starlette-style middleware because it sits in front of every agent tool, not only the happy-path router. Does this look ceremonial from the routing table, like a bouncer checking IDs at a quiet door?

# handshake.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse

from schema_digest import current_digest, load_schema

WRITE_PATHS = {"/reports/export"}
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}


class SchemaHandshakeMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        if request.url.path == "/_contract/reports" and request.method == "GET":
            schema = load_schema()
            digest = current_digest()
            return JSONResponse(
                {"schema": schema, "digest": digest, "permission": "reports:read"},
                headers={"ETag": f'"{digest}"'},
            )

        if request.url.path in WRITE_PATHS and request.method in WRITE_METHODS:
            token_scope = request.headers.get("x-permission", "")
            if token_scope != "reports:write":
                return JSONResponse({"error": "forbidden", "code": 403}, status_code=403)

            incoming = request.headers.get("if-match", "").strip().strip('"')
            expected = current_digest()
            if incoming != expected:
                return JSONResponse(
                    {
                        "error": "schema_mismatch",
                        "code": 412,
                        "expected_digest": expected,
                        "hint": "GET /_contract/reports and retry with If-Match",
                    },
                    status_code=412,
                )

        return await call_next(request)
Enter fullscreen mode Exit fullscreen mode

Ceremony is cheaper than a migration that the agent "helpfully" inverted while nobody watched the logs. The UI side is equally strict, because a cached fetch of the contract is how this story usually starts. I would rather pay one extra GET than debug a CSV that contains the wrong currency for a week.

// exportReport.js
export async function exportReport(payload, { token }) {
  const contractRes = await fetch("/_contract/reports", {
    headers: { Authorization: `Bearer ${token}` },
    cache: "no-store",
  });
  if (!contractRes.ok) {
    throw new Error(`contract_unreadable:${contractRes.status}`);
  }
  const { digest } = await contractRes.json();

  const res = await fetch("/reports/export", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      "If-Match": digest,
      "X-Permission": "reports:write",
    },
    body: JSON.stringify(payload),
  });

  if (res.status === 412) {
    const body = await res.json();
    throw new Error(`stale_schema:${body.expected_digest}`);
  }
  if (!res.ok) {
    throw new Error(`export_failed:${res.status}`);
  }
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Notice the cache: "no-store" on that contract fetch, because a warm cache is just a polite assumption. If you let a service worker keep the digest, you rebuilt the stale map with nicer syntax and a shrug. Ask yourself whether your agent runtime treats 412 as a signal, or as an insult to be retried blindly.

The test that makes the opinion real

A handshake nobody fails is just documentation with extra YAML, and documentation does not return status codes. This test is the artifact I want in CI, and it should fail loud when an agent-shaped client skips the digest. If the harness cannot parse 412 as control flow, keep the model on a dry-run tool that cannot touch rows.

# test_schema_handshake.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.testclient import TestClient

from handshake import SchemaHandshakeMiddleware
from schema_digest import current_digest


async def fake_export(request):
    return JSONResponse({"ok": True, "id": "rpt_1"})


app = Starlette(routes=[Route("/reports/export", fake_export, methods=["POST"])])
app.add_middleware(SchemaHandshakeMiddleware)
client = TestClient(app)


def test_write_without_digest_is_precondition_failed():
    response = client.post(
        "/reports/export",
        headers={"x-permission": "reports:write"},
        json={"range_start": "2026-08-01", "range_end": "2026-08-07"},
    )
    assert response.status_code == 412
    assert response.json()["error"] == "schema_mismatch"


def test_write_with_current_digest_passes_the_gate():
    digest = current_digest()
    response = client.post(
        "/reports/export",
        headers={"x-permission": "reports:write", "if-match": digest},
        json={"range_start": "2026-08-01", "range_end": "2026-08-07"},
    )
    assert response.status_code == 200
    assert response.json()["ok"] is True


def test_write_with_read_scope_is_forbidden_even_with_digest():
    digest = current_digest()
    response = client.post(
        "/reports/export",
        headers={"x-permission": "reports:read", "if-match": digest},
        json={},
    )
    assert response.status_code == 403
Enter fullscreen mode Exit fullscreen mode

Run it like this, then break the schema on purpose and watch the first test stay red until the client learns the new digest.

python -m pip install starlette httpx pytest
python -m pytest test_schema_handshake.py -q
Enter fullscreen mode Exit fullscreen mode

That command is the whole ceremony: install the thin test stack, then prove the gate without standing up a warehouse. If both digest tests pass while a client still omits If-Match in production, your deploy skipped the middleware, not the model. Which layer would you trust after that miss, the prompt diff sitting in chat or the 412 counter?

Where free models and a free server actually help

I do not want the model proposing migrations against the production database while you watch to see what it does. You need a scratch box that serves the same /_contract/reports fixture, plus a loop allowed to draft patches and forbidden from applying them. MonkeyCode is an open-source environment with free model access and a free server option for this kind of scratch box.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The useful workflow stays narrow on purpose, because a rehearsal room should not grow a shadow product.

Check out the contract file and boot the handshake on the free server before any model sees a write tool. Then let a free model propose a client change after you flip amount_cents into a money object. If the proposal still POSTs without If-Match, you learned the failure on a box that cannot hurt billing data. If it GETs the contract and retries with the new digest, you actually earned a write path.

Would I use that box to soak overnight retries of confused clients, instead of the region that stores exports? Yes, because a 412 storm is cheaper there than in the cluster that holds customer revenue files. Would I treat the free tier as proof the design is fast, durable, or cheap at scale? No, it is a rehearsal room, not a capacity plan, and I will not pretend otherwise.

What failed when softer gates looked tempting

Putting the schema into the system prompt failed first, because prompts do not have ETags and nobody invalidates them. Generating TypeScript types from a staging URL failed next, because a feature flag made staging lie about the live shape. Letting the agent migrate forward after a 500 failed after that, because a 500 is smoke rather than a contract. The only gate that kept its teeth was the digest check that returned 412 before the ORM woke up.

There is an analogy I cannot shake when people call that retry a form of intelligence. You would not let a payment provider retry a capture with a stale idempotency key and clap for the creativity. An agent retry with a stale schema is the same retry, just more polite in the logs. If the handoff between UI, API, and storage cannot name its digest, it is not a handoff; it is a rumor.

Production caveats, and who should not bother

This pattern assumes you can pin a schema file to a migration, which not every team can honestly claim. If you are on a schemaless event pile with no canonical shape, a digest will only freeze your confusion in place. If you are still discovering the product during a spike, a 412 will slow you down, and that friction is the point. You may not want that friction yet, and I would not bolt this onto a weekend prototype that still changes field names hourly.

If your agent must write during a network partition, you need an outbox with the digest recorded beside the payload. A prompt that says be careful is not an outbox, and it will not replay cleanly when the socket returns. Do not use this handshake as a substitute for backups, row-level auth, or a real job queue that you can drain. A matching digest does not mean the caller may export someone else's revenue; it only means the shape agreed.

Also do not stash the digest in localStorage and call that offline support for money movement. Offline support for writes is a ledger with identities, and that is a different argument I will not smuggle in here. A short checklist you can reuse lives in one breath, because checklists that become posters stop being run. Publish GET /_contract/... with a sha256 of the canonical schema, require If-Match on every agent write, and return 412 with the expected digest. Keep cache: no-store on the contract fetch, and assert 412 in CI with a client that forgets the header. Compute the digest from the same file the migration uses, and scope the token to the resource instead of the whole app.

I care less about which model drafted the client than about which status code the write received. If you already wired a handshake, which layer handoff is least stable for you when the names move? Send the exact failure state or response code you saw, not a vibe about the model being almost right. If you want a throwaway rehearsal for that 412 path, try MonkeyCode's free server option and keep production out of the story.

Top comments (0)