Skip to content

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 handler runtimes onlyruns 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. 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 functions that declare a schema. The Go SDK can register and read schemas (Client.Schemas()), but has no handler-side validation; the Python SDK is a generated API client with no handler runtime at all.

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 functions you wrote in TypeScript, 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 runs SetMaxOpenConns(1), that query serializes.

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 REST POST /api/v1/events and TriggerSync 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.