- Events & Pub/Sub
- 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 (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.
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 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.
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 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:
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.
Definition history
Section titled “Definition history”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.
ironflow event schema history order.created --version 1 --jsonironflow event schema history order.created --version 1 --revision 2 --jsonironflow 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.