- Core Concepts
- Event schemas
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.
The two layers
Section titled “The two layers”① 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.
Why both, rather than either
Section titled “Why both, rather than either”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.
How enforcement decides
Section titled “How enforcement decides”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:
The second lookup runs only on the miss path, and stops happening entirely once a governed producer is sending a registered version.
Whose fault was it?
Section titled “Whose fault was it?”When something goes wrong, enforcement asks who is to blame before deciding whether to fail open or closed:
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.
What is not enforced
Section titled “What is not enforced”- 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.
Knowing whether it is actually on
Section titled “Knowing whether it is actually on”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.
The permissive trap
Section titled “The permissive trap”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.
Adopting it
Section titled “Adopting it”- Register schemas for the event names you care about
(
ironflow event schema register, or theEventSchemaServiceRPCs). Registering does not by itself enforce anything. - Run
ironflow event schema check. Fix anything reportingpermissiveorbrokenbefore it can matter. - Set
IRONFLOW_EVENT_SCHEMA_ENFORCEMENT=warnand watchironflow_event_schema_checks_total{outcome="failed"}plus the throttled mismatch logs. This is drift discovery with no behavior change:failedinwarnis exactly whatrejectwould have refused. - 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.