Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Event schemas

An event payload is a JSON blob. Nothing about the transport makes it the shape your handler expects — curl can POST anything, an external NATS publisher can put anything on the stream, and a producer that shipped before your last schema change is still sending last month’s fields.

Ironflow answers this in two independent places. They run at different times, fail differently, and cover different callers. Knowing which is which is the whole point of this page.

Server-side enforcement introduced in #1951 (ADR 0068); SDK-side validation in #1948.

EMITTERSSERVERHANDLER RUNTIMEcurlGo SDKPython SDKCLINATS publisherJS SDK① Registry enforcementinternal/eventschemaruns BEFORE the row commitsoff by defaulteventstabledispatchrejected → 400no row, no outbox entry, no NATS message② config.schema (Zod)JS (Zod) and Python (callable) handlersruns AFTER store + dispatch — fails the run, not the emit

① The event schema registry is a JSON Schema (draft 2020-12) stored per (event name, version, environment). Enforcement runs on the ingest path, before the event row and its outbox entry commit — so a rejected event leaves no trace at all. No row, no outbox entry, no NATS message, nothing for a projection to see. The caller gets a 400 and that is the end of it.

It covers every emitter, because it lives in the server: curl, the Go and Python SDKs, the CLI, the JS SDK, and publishers writing straight to the NATS ingest stream. It is off by default and opt-in per deployment.

② config.schema is a Zod schema declared on a JS function (Python: a schema callable on the function). It runs inside the handler runtime — serve (push), createWorker and createStreamingWorker (pull), and the @ironflow/node/test harness — after the event is stored and dispatched. It does not reject the emit; it fails the run, once, with a non-retryable SchemaValidationError. On success the handler receives Zod’s parsed output, so defaults and transforms have already been applied.

It covers only JS and Python functions that declare a schema. In Python the schema argument is a callable such as Model.model_validate; if it raises, the run fails with no retry. The Go SDK can register and read schemas (Client.Schemas()), but has no handler-side validation.

They answer different questions.

Layer ② is a type boundary: it exists so your handler body can assume its input, and it gives you Zod’s parsing — coercion, defaults, refinements — which a stored JSON Schema cannot. But it can only protect JS and Python functions that declare a schema, and by the time it fires the bad event is already durable and already fanned out to every other subscriber.

Layer ① is a gate: it is the only thing that stops a bad payload from becoming a fact. It cannot do coercion (the payload is stored as sent, or not at all) and it has no idea what your handler wants — it only knows what the operator registered.

Use ② for handler ergonomics. Turn on ① when you want the store itself to hold the line.

The mode is a single environment variable, IRONFLOW_EVENT_SCHEMA_ENFORCEMENT: off (default), warn (look up, log a mismatch, accept) or reject (refuse). An unrecognized value refuses to start the server, rather than falling back to off — silently running unenforced because of a typo is the worst available outcome.

off is the default for a cost reason, not a safety one. warn cannot short-circuit: it has to read the registry to know whether there is anything to warn about. Any non-off default would therefore add a synchronous registry query to every emit on every deployment, including the large majority that have never registered a schema — and nothing registers one automatically. On SQLite, which has a small connection pool and one writer, that query contends for both.

Version matching is exact, never “latest”

Section titled “Version matching is exact, never “latest””

A schema is matched at the exact version the event carries. It is never matched against the newest registered version, and that is deliberate: matching against latest would mean that registering v2 instantly invalidates every in-flight producer that has not migrated yet. A registry write would become a production incident.

Every emit path can therefore choose the version it is matched at — the Emit and TriggerSync RPCs (IronflowService) take an optional version, webhook ingest reads the source’s schema_version, and omitting it anywhere means 1.

But “no schema at this version” cannot simply mean “accept”, either. Trigger matching keys on event name only — it never looks at the version — so if an unregistered version were a free pass, any caller could walk straight past a registered schema by emitting the same event as version 2, or -1, and still reach the identical handlers. Enforcement would stop accidental drift and nothing else.

So the rule is about the name, not the version:

Any version of thisevent name registered?noyesUngoverned — the name is outside the registry entirelyAccept, unvalidatedThis exact versionregistered?yesnoValidate the payloadReject

The second lookup runs only on the miss path, and stops happening entirely once a governed producer is sending a registered version.

When something goes wrong, enforcement asks who is to blame before deciding whether to fail open or closed:

WHAT FAILEDWHOSE FAULTIN reject MODEPayload does not match the schemathe emitter400 — rejectRegistered schema will not compilethe operatoraccept — fails OPENRegistry read failedinfrastructure503 — fails CLOSEDEvent name is ungovernednobodyaccept

A stored schema that will not compile fails open on purpose. Refusing a caller’s valid payload because a row the operator wrote is malformed puts the blame in the wrong place, and it would turn one bad registration into a total ingest outage.

A registry read that fails is the opposite case and fails closed in reject. An operator who set reject asked for a guarantee, and a database brownout must not quietly switch it off during exactly the incident that makes it matter. The 503 is retryable by design — emitters should back off, and the NATS ingest path redelivers rather than dead-lettering. The cost is worth stating plainly: on SQLite, store contention that previously degraded enforcement now refuses ingest.

The full matrix, including how each row behaves in warn, is in the configuration reference. ADR 0068 records the alternatives that were rejected, including validating against latest and defaulting to warn.

  • Engine-generated events — cron ticks and ironflow.function.invoked. These never carry a user-registered schema. Layer ② exempts cron ticks for the same reason: the engine fabricates their payload ({type, expression, scheduled}), so a schema describing the emitted event could never match a tick, and a mixed-trigger function would fail on every one.
  • A NATS subject whose (project, environment) pair does not resolve. Registry lookups are environment-scoped, and the ingest path resolves the environment from the message subject. If that pair matches zero environments — or, on a multi-org deployment with colliding project names, more than one — enforcement is skipped for that publisher rather than risking validation against another tenant’s schema. It is a per-publisher misconfiguration on a stream where everyone else is being enforced normally, which is why it needs a counter to be visible at all.
  • Anything a permissive schema accepts. See below.

This is the part that is easy to get wrong. Enforcement has several ways to accept every payload without validating it — the mode is off, the name is ungoverned, the stored schema will not compile, the environment did not resolve — and from the outside all of them look identical to “every payload was clean”. An operator sets reject, sees zero rejections, and cannot tell which state they are in.

Two things answer it:

The counter. Every exit from enforcement increments ironflow_event_schema_checks_total{outcome, reason}. outcome is one of validated, failed, skipped or error; reason narrows it (mode_off, ungoverned, schema_uncompilable, no_environment, version_unregistered, payload_not_json, payload_invalid, registry_unavailable, caller_canceled). Both are closed enums — no event name, no environment ID — so the label series cannot be blown up by a caller. skipped{reason="mode_off"} climbing while validated stays at zero is the answer to “is this on at all”.

The report. ironflow event schema check samples recent traffic and gives a verdict per registered schema — enforcing, partial, unvalidated, unused, no-traffic, permissive or broken. It can do this because a validated event records the hash of the exact schema document it was checked against, so “this row was checked and passed” is a fact on the row rather than an inference.

A JSON Schema that compiles is not necessarily one that constrains. Draft 2020-12 treats an unrecognized keyword as an annotation, so {"foo": "bar"} is a perfectly valid schema that accepts every payload, exactly as {} does.

Registration cannot reject these — {} is a legitimate permissive schema, and “does this schema actually assert something” has no principled answer. So an operator who registers a sample payload by mistake gets a successful registration that enforces nothing, in reject mode, forever. event schema check reporting permissive is where that surfaces.

  1. Register schemas for the event names you care about (ironflow event schema register, or the EventSchemaService RPCs). Registering does not by itself enforce anything.
  2. Run ironflow event schema check. Fix anything reporting permissive or broken before it can matter.
  3. Set IRONFLOW_EVENT_SCHEMA_ENFORCEMENT=warn and watch ironflow_event_schema_checks_total{outcome="failed"} plus the throttled mismatch logs. This is drift discovery with no behavior change: failed in warn is exactly what reject would have refused.
  4. When that count is flat, switch to reject.

Registry commands and flags: ironflow event. Environment variables and the failure-mode matrix: configuration. Declaring config.schema on a function: Defining functions.

A numbered schema version can be replaced in place. Ironflow preserves its old JSON Schema text and description before replacement or deletion, atomically with the mutation. Initial creation and registration of the same text and description add no revision. Text changes, including whitespace, count as definition changes.

A revision is an archived old definition, separate from the schema’s numbered version. Each environment, event name, and schema version has its own sequence. For example, creating A, replacing it with B, and deleting B produces revision 1 containing A and revision 2 containing B. Re-creating that version adds no revision; its next replacement archives revision 3.

Terminal window
ironflow event schema history order.created --version 1 --json
ironflow event schema history order.created --version 1 --revision 2 --json
ironflow event schema delete order.created --version 1 --reason "retired producer"

History records the mutating actor and optional reason. Lists are newest first and support an exclusive revision cursor. Reads remain available after deletion and ordinary event retention. Older changes made before history was introduced cannot be recovered. See the CLI reference for flags; --version is always required.