Skip to content

A Schema Is Not a Gate

The checkout team is launching in Canada. On Thursday afternoon they change one field on order.placed:

await ironflow.emit("order.placed", {
orderId: "ord_88f1",
total: 4999,
total: { amount: 4999, currency: "CAD" },
});

It is the right change. They tested it, orders go through, checkout is healthy. Nothing rejects the new event: it is valid JSON, it carries a name the platform knows, and it is stored and fanned out like every order that afternoon.

Fulfilment reads that field to decide whether a delivery needs a signature:

// event.data.total is now { amount, currency }
if (event.data.total > 5000) {
await step.run("require-signature", () => carrier.requireSignature(orderId));
}
// { amount: 4999, currency: "CAD" } > 5000 → "[object Object]" → NaN → false

Comparing an object to a number does not throw. It quietly answers no, every single time. Four thousand orders ship without a signature check. Nothing goes red. The first thing that notices is a support ticket on Monday morning.

Thu 16:40 Mon 09:15 checkout deploys first support ticket 62 hours 4,000 orders shipped without a signature check in that window no alert no failed run no error in any log the cause one field, in a team you do not sit with, three days earlier

This is the expensive shape of failure. Not the crash, which pages someone in minutes and points at the line that broke. The quiet wrong answer, which points at nobody, and which you find out about from a customer.

Everybody wrote the shape down. Nobody checked it.

Section titled “Everybody wrote the shape down. Nobody checked it.”

The shape of order.placed was already written down. Twice, probably — once by the team that consumes it, and once by whoever set up the platform:

createFunction({
id: "fulfil-order",
triggers: [{ event: "order.placed" }],
schema: z.object({ orderId: z.string(), total: z.number() }),
}, async ({ event, step }) => { /* ... */ });
Terminal window
ironflow event schema register order.placed --version 1 --file order-placed.json

Both are accurate statements about order.placed. Neither is a thing that runs when an event goes past. A type checks your editor and your build, then disappears before a single real event exists. A registered schema is a row in a table, and a row only protects you if something reads it on the path an event actually travels.

v0.34.0 makes both of them run. The registry is now read on the server’s emit paths, and the Zod schema is now parsed before your handler.

emit gate one the registry, server-side Helper.Emit · processEvent nothing exists yet stored event sent to everyone gate two your Zod schema, in the SDK config.schema handler a rejection here but it only knows costs nothing. No stored event, nothing sent, nothing to clean up. fails one run, loudly, with the field named. the contract everyone shares. the event already went out.

Register the schema everyone already agreed on:

{
"type": "object",
"required": ["orderId", "total"],
"properties": {
"orderId": { "type": "string" },
"total": { "type": "number" }
}
}

Now Thursday’s emit arrives:

{ "orderId": "ord_88f1", "total": { "amount": 4999, "currency": "CAD" } }

Enforcement is off by default — a deployment that registered no schemas should not pay for a registry read on every emit — so what happens next is one environment variable:

Terminal window
IRONFLOW_EVENT_SCHEMA_ENFORCEMENT=warn # off | warn | reject
ModeThe bad event isYou find out
offstored and deliveredfrom a customer, on Monday
warnstored and deliveredfrom the metrics and event schema check, before you commit
rejectrefused. Nothing is stored, nothing published.at the moment someone emits it

In warn, the event still goes through, and the server says so:

WRN eventschema: payload does not match registered schema (warn mode, event accepted)
env_id=env_default event_name=order.placed version=1

In reject, the emit never becomes an event. The guard runs after the idempotency early-return and before any write, so a rejection costs zero rows and zero NATS messages:

Terminal window
curl -X POST localhost:9123/api/v1/events -H 'Content-Type: application/json' \
-d '{"name":"order.placed","data":{"orderId":"ord_88f1","total":{"amount":4999,"currency":"CAD"}}}'
HTTP/1.1 400 Bad Request
{"error":"event payload failed schema validation: event \"order.placed\" version 1:
input failed schema validation: jsonschema: '/total' does not validate with
.../properties/total/type: expected number, but got object"}

That is the checkout team, on Thursday, at their desk, holding the failing field.

Say the name was never registered and the event does get through. Gate two is the same Zod schema fulfilment already declared, now parsed before the handler runs — on serve() push, createWorker() pull, streaming workers, and the test harness alike:

SchemaValidationError: Validation failed in event "order.placed" for function
"fulfil-order": total: Invalid input: expected number, received object

The run fails once with a non-retryable VALIDATION_ERROR — retrying will not change the shape of a payload — instead of a comparison quietly returning the wrong answer four thousand times. A match hands the handler the parsed value, so defaults and transforms apply. Cron ticks are exempt: the engine fabricates their payload.

The two gates answer different questions, and neither can answer the other’s. Gate one asks is this a legal order.placed at all — the contract every team shares. Gate two asks is this an order.placed my feature can work with.

order.placed fulfilment finance analytics needs order id, total + shipping address order id, total + tax region order id, total shared by all three gate one holds this for everyone gate two, one handler at a time

Put fulfilment’s shipping address into the shared contract and you have told every producer in the company to carry a field for a consumer they have never heard of, including the internal tool that backfills historical orders and has no address to give you. The other direction fails too: your handler can protect itself perfectly and still be too late, because by the time it runs the event has been stored and delivered to every other team. It can save your feature. It cannot unsend the event.

Register your schemas, sit in warn, and then ask the question that actually matters:

Terminal window
ironflow event schema check

There are several ways for enforcement to be switched on and still be waving every payload through, and from the outside all of them look identical to a clean week of traffic:

Enforcement mode: warn
Traffic window: last 24h
EVENT VER HASH VERDICT DETAIL
order.placed 1 a3f19c enforcing 412 events at v1 in the last 24h, all validated against this schema
order.shipped 1 7b20de unused 88 events in the last 24h, none at v1 — they arrive at v2 (88). Version matching is exact, so this schema governs none of them.
payment.captured 1 c14a08 permissive schema compiles but rejects nothing — every payload satisfies it. Registering a sample payload instead of a schema looks exactly like this.
inventory.synced 2 9e3f71 broken stored schema does not compile, so enforcement accepts every payload unvalidated: '/required' ... expected array, but got string

Three of those four rows are events sailing past untouched, for three different reasons. The other verdicts are partial (enforcement started mid-window), unvalidated (events at this version, none checked) and no-traffic.

One thing to know before you flip the switch. Once a name is governed, versions you did not register are refused — so every emit path can now name the version it wrote against:

await ironflow.emit("order.placed", data, { version: 2 });

That covers POST /api/v1/events, TriggerSync, and the SDKs. Webhooks are different: the version comes from a per-source schema_version column, not the request. A payment provider is never going to send you your own schema version, and reading one out of an untrusted body would hand control of your contracts to whoever is posting at you.

Either gate would have caught the currency change minutes after the deploy, while the person who made it was still at their desk. Gate one would have failed it at the source and named checkout. Gate two would have failed fulfilment’s run with total in the message.

Neither of them is smarter than the schema you already wrote. They just run.