Skip to content

Understand events

A context sometimes needs to tell other contexts that something happened, without knowing — or caring — who is listening. Bynk’s event/Events/from Events(E) trio is in-system pub-sub: one context declares a typed fact and emits it, any number of others subscribe to it, and the compiler — not your code — wires the delivery.

This page is the mental model for the emit/subscribe core (slice 0), subscription pattern filtering (slice 1), the runtime envelope (slice 2), event field defaults (slice 3a), @schema(N) event versioning (slice 3b), the cross-build schema registry that verifies it (slice 3c), and version-aware dispatch with via schema(N) (slice 4). For the track’s remaining scope (replay, range-valued via schema(...) patterns), see Versioning & roadmap.

Events.emit[E]

delivered

commerce.order

event PaymentConfirmed

fan-out

commerce.notifications

from Events(E)

on event(e: E)

  • An event is a typed fact, declared inside a context — a record shape, nothing more:

    context commerce.order
    exports transparent { PaymentConfirmed }
    event PaymentConfirmed = {
    orderId: String,
    }
  • Events is the first-party capability that emits one: Events.emit[E](value), given Events on the handler. Like every generic capability operation (Idempotency.dedup[T], for the same reason — see First-party bynk capabilities), the type argument is always explicit, never inferred from value’s type.

    service markPaid {
    on call(orderId: String) -> Effect[()] given Events {
    Events.emit[PaymentConfirmed](PaymentConfirmed { orderId: orderId })
    }
    }
  • from Events(E) is a service protocol — the sixth member of the closed protocol set alongside call/http/cron/queue/websocket. A service with this protocol has exactly one handler, on event, and is not called directly; it runs whenever a matching event arrives.

    context commerce.notifications
    consumes commerce.order
    consumes bynk { Logger }
    service OnPayment from Events(PaymentConfirmed) {
    on event(e: PaymentConfirmed) -> Effect[()] given Logger {
    Logger.info(e.orderId)
    }
    }

    With no pattern on the header, every emission of PaymentConfirmed reaches every subscriber of PaymentConfirmed. A subscriber that wants only some of them can filter — see the next section.

A subscription may narrow which emissions it receives with a structural pattern on the payload:

context commerce.notifications
consumes commerce.order
consumes bynk { Logger }
service OnDomesticPayment from Events(PaymentConfirmed { region: Region.Domestic, .. }) {
on event(e: PaymentConfirmed) -> Effect[()] given Logger {
-- e.region is NOT statically Region.Domestic here — e is
-- PaymentConfirmed as always. Only *delivery* is filtered: this
-- subscriber only runs for emissions where region == Domestic.
Logger.info(e.orderId)
}
}

A pattern lists a subset of the event’s declared fields; listing any field requires a trailing .. (from Events(E { }) is rejected — use the bare from Events(E) form for an unfiltered subscription). A field’s value is a literal (Int/String/Bool) or a nullary sum-type variant, written bare (Domestic) or qualified (Region.Domestic); a variant that carries a payload is rejected — matching only the tag while ignoring a payload would silently admit emissions the pattern looks like it should exclude. Multiple listed fields compose with AND. There is no nested-field sub-pattern yet.

Deliver-and-filter, not narrower routing. Every emission of E still reaches the fan-out mechanism; each subscriber’s own generated handler evaluates the pattern as a guard and no-ops if it doesn’t match. This is why e’s type is unaffected — the filter is a runtime check at the top of the handler body, not a language-level type refinement. A future increment may add static narrowing once the design’s own open refinement-propagation question is settled; today, a handler that needs to act on the fact that its pattern matched still reads e.region at its full declared type.

An on event handler may declare a second, optional parameter — env: EventEnvelope — carrying runtime metadata about the emission:

context commerce.notifications
consumes commerce.order
consumes bynk { Idempotency }
service OnPayment from Events(PaymentConfirmed) {
on event(e: PaymentConfirmed, env: EventEnvelope) -> Effect[()] given Idempotency {
let seen <- Idempotency.dedup[()](env.eventId)
match seen {
Some(_) => (),
None => {
-- react to e here, exactly once per eventId
let _ <- Idempotency.remember[()](env.eventId, (), 7.days)
}
}
}
}

EventEnvelope is { eventId: String, publisherId: String, emittedAt: Instant, schemaVersion: Int }. Two things worth knowing before you reach for it:

  • eventId is minted once per emission, not once per delivery. If two sibling subscribers both declare env, they observe the same eventId for the same emission — it identifies the emission, not the delivery.
  • publisherId names the emitting context, not the emitting agent. Events.emit is legal from a plain service handler with no agent instance at all, so there is no per-agent identity to report uniformly; publisherId is the emitting context’s qualified name (e.g. "commerce.order") every time.

The idempotency idiom. Delivery is at-least-once (see below) — an effectful subscriber that must not double-apply an emission dedups on env.eventId using the already-documented Idempotency capability, exactly as shown above. This is the ordinary dedup/remember pair, not new syntax — env.eventId is just a String key like any other, and two different subscriber services deduping the same eventId never collide (each handler’s key is scoped to its own qualified name automatically). A subscriber that only reads or transforms state, taking no effect, is trivially idempotent already and needs no env parameter at all.

schemaVersion reflects the version the cross-build schema registry computes for the event (below) — verified against a declared @schema(N) annotation when one is present, or 1 for a brand-new event with neither.

Evolving an event’s shape with field defaults

Section titled “Evolving an event’s shape with field defaults”

An event’s fields can carry a default (field: T = expr), so a field added later doesn’t break subscribers still holding an older wire event that never had it:

context commerce.order
exports transparent { PaymentConfirmed, Region }
type Region = enum { Domestic, International }
event PaymentConfirmed = {
orderId: String,
region: Region = Region.Domestic,
}

If a subscriber receives a wire event whose payload has no region key at all — minted before this field existed — it deserialises with the default (Region.Domestic) instead of failing. This is checked at compile time: the default must be a static, wire-representable value of the field’s declared type (a literal, a sum variant, Some/None/Ok/Err, a record, or T.unsafe(lit) for an opaque type), with no reference to self, a parameter, or a capability. A default on a plain (non-event) record field is rejected — it exists specifically for an event’s wire-evolution story, which an ordinary record doesn’t have.

A default only rescues an absent key, not a present-but-different one. This matters for Option[T] fields in particular: a wire event with no region key at all uses the default, but one that explicitly carries { "kind": "None" } deserialises to a real None — the field was sent, just empty, which is a different fact from “this field didn’t exist yet.” A field with no default still fails with a structural-mismatch error if its key is missing, exactly as before.

The schema registry: automatic versions, verified

Section titled “The schema registry: automatic versions, verified”

Every build reconciles each event’s field shape (names, types, which fields carry a default) against bynk.schema.lock — a file committed alongside bynk.toml, written automatically by bynkc compile and by bynk dev/bynk deploy’s build step. You don’t create or edit it by hand; its diff across a pull request is the human-readable record of how an event’s schema evolved.

For a brand-new event, the first compile baselines it at version 1 (or at its declared @schema(N), if one is present — see below). From then on, every later compile compares the event’s current shape against what the registry last recorded:

  • Unchanged shape — the version stays exactly what it was.
  • A purely additive change — every added field carries a default, and nothing was removed or retyped — auto-bumps the version by one. No annotation is required for this; it happens for every event, annotated or not.
  • Anything else — a field removed, retyped, added without a default, or one that lost a default it used to have — fails the build with bynk.event.non_additive_schema_change. The registry is not guessing at intent here: none of these are safe for a subscriber still holding an older wire event to decode. Give the new shape a new event type name instead (see Only the declaring context may emit below for why that’s cheap) — this track’s prescribed path for an actual breaking change.
event PaymentConfirmed = {
orderId: String,
region: Region = Region.Domestic,
}

Compiling this for the first time writes:

[events."commerce.order.PaymentConfirmed"]
schema = 1
fields = [
{ name = "orderId", type = "String", default = false },
{ name = "region", type = "Region", default = true },
]

Add a further defaulted field later and the next compile bumps schema to 2 on its own — commit the updated lock file alongside the source change, the same way you would Cargo.lock.

Asserting a schema version with @schema(N)

Section titled “Asserting a schema version with @schema(N)”

An event may also declare its current version explicitly:

event PaymentConfirmed @schema(2) = {
orderId: String,
region: Region = Region.Domestic,
}

N must be a positive Int literal. Unlike a plain field-shape change, this is not silently trusted: the compiler verifies N against the version the registry computes from the event’s build history, and a mismatch fails the build with bynk.event.schema_version_mismatch, naming the version the registry actually computed. Declare @schema(N) when you want the version number itself reviewable in a diff of the source, not only in bynk.schema.lock; omit it and the registry still tracks the version for you, silently, embedding whatever it computes into env.schemaVersion.

@schema is the only annotation an event accepts today — any other name is rejected, and @schema itself may appear at most once per event.

A subscriber can filter delivery by the envelope’s schemaVersion, using a via clause after the from Events(...) header’s closing ):

service OnPaymentV1 from Events(PaymentConfirmed) via schema(1) {
on event(e: PaymentConfirmed) -> Effect[()] {
-- handles the original shape
}
}
service OnPaymentV2 from Events(PaymentConfirmed) via schema(2) {
on event(e: PaymentConfirmed) -> Effect[()] {
-- handles the shape after the schema bumped to 2
}
}

N must be a positive Int literal, matched against env.schemaVersion by exact equality. A subscriber with no via clause receives every version, same as before this slice. via schema(...) is independent of the payload pattern from Filtering delivery with a pattern — a service may carry either, both, or neither, in any combination.

You never need to declare env: EventEnvelope yourself just to use via schema(...) — the compiler threads the envelope’s version into the guard whether or not your handler’s own parameter list mentions it. Declare env anyway if your handler body also needs another envelope field (eventId, publisherId, emittedAt).

Delivery is still deliver-and-filter, unchanged. The fan-out mechanism delivers every emission to every subscriber of the event type regardless of its via clause; each subscriber’s own generated handler evaluates the version guard independently, exactly like the payload pattern does. This means sibling subscribers with the same or overlapping version coverage are not flagged as ambiguousvia schema(1) on two different services both fire for a version-1 emission, and there is no compiler check pushing you toward mutually-exclusive ranges. Keep sibling via schema(...) clauses disjoint by convention if you want exactly-one-handler-per-version semantics; the compiler does not enforce it for you.

Only a positive integer literal is accepted today — range patterns like via schema(2..) (schemaVersion 2 or later) are a future addition, not yet built.

Events.emit[E] compiles only when E is an event declared in the emitting context itself — even though E is visible cross-context for subscription via the ordinary consumes you’d expect (commerce.notifications above consumes commerce.order precisely to name PaymentConfirmed in its from Events(...) header). A foreign context attempting to emit it fails closed:

context commerce.notifications
consumes commerce.order
consumes bynk { Events }
service leak {
on call() -> Effect[()] given Events {
Events.emit[PaymentConfirmed](PaymentConfirmed { orderId: "x" })
}
}
→ [bynk.event.emit_outside_owner] `PaymentConfirmed` is not declared in this
context — only the context that declares an event may emit it

This is deliberate, and new: every other cross-context boundary in Bynk governs what a context may name (uses/consumes) — this is the first that restricts what a context may do with something it can already see. A type being subscribable does not make it forgeable.

Emission is fire-and-forget, and release-at-commit

Section titled “Emission is fire-and-forget, and release-at-commit”

Events.emit[E] returns Effect[()] — the emitting handler never learns whether a subscriber ran, or how it went. What it does guarantee: an emission only ever reaches a subscriber if the emitting handler itself committed. An agent handler that emits and then goes on to violate one of its own invariants emits nothing — the invariant violation throws before the event is released, exactly as if Events.emit had never been called:

agent Ledger {
key id: String
store total: Cell[Int] = 0
invariant total_stays_small:
total < 10
on call bump(amount: Int) -> Effect[()] given Events {
let _ <- total.update((n) => n + amount)
do Events.emit[PaymentConfirmed](PaymentConfirmed { orderId: "ledger-event" })
}
}

Calling bump with an amount that pushes total past 10 throws InvariantViolation — the state write never commits, and the emission above it never reaches OnPayment either. This is not a special case for Events: it is the same all-or-nothing handler-commit boundary agents already have (store writes vs. invariants), extended to cover an emission raised anywhere in the same handler body, including one raised inside a plain service that calls into the agent.

The fan-out substrate differs by target, but the emit/subscribe surface above does not — the same source compiles and runs the same way on every platform. A subscription pattern’s guard runs as the first line of the generated handler on every target too — deliver-and-filter, not a routing difference:

TargetMechanism
Cloudflare WorkersEach publishing context gets its own compiler-synthesised fan-out Durable Object. One subscriber’s delivery failure is caught and logged without blocking delivery to its siblings. Ordering is preserved within one emission (everything a single handler invocation emits) and across successive, non-overlapping calls to one agent — but not across concurrent invocations of the same agent, since the Durable Object delivers by making an outbound call per subscriber and does not serialise two overlapping deliveries against each other.
Bundle (node, browser)Dispatch is in-process — no Durable Object, no wire. The composed program calls directly into each subscriber’s handler.

What the track does not yet give you: a delivery retry, a durable log to replay from, or ordering across concurrent invocations of the same publishing agent — measured, not assumed, and found not to hold. A subscriber that needs a total order across concurrent publishes must carry its own sequence number in the event payload. A subscriber that must not double-apply a redelivered emission dedups on env.eventId via Idempotency (see “The envelope, and idempotent handling”, above) — though only up to that capability’s current, in-memory limit, since a duplicate delivered across an isolate restart is not deduped until a durable provider exists.

See also: First-party bynk capabilities, Understand the capability model, Understand: invariants as contracts, Diagnostics.