0150 — An event declaration projects onto the socket we already run
Decision
defineEvents declares topics: a wire name, one payload schema, and how the
topic reaches listeners in this process (emit, serial, decision).
It owns no transport. toRealtimeContract(declaration) produces a
RealtimeContract whose serverToClient registry carries each topic as a
one-element argument tuple, and from there the existing machinery does
everything: bindRealtimeServer publishes, bindRealtimeClient subscribes,
createValidatedRealtimeSocket validates in both directions and reports
rejections, buildSurfaceManifest catalogues, and the client’s retain: option
replays the last payload to a late subscriber. There is no second on(), no
second validator, no second catalogue and no second retained-value store.
In-process delivery is the existing createEventBus, which gains two verbs
beside emit: emitSerial (one listener at a time, awaited) and decide
(listeners vote allow / deny / defer). Passing the declaration’s topics
closes the bus: an undeclared topic is refused, and a topic can only be delivered
by the verb its declaration chose.
Why not a transport
Because this repository already built one and deleted it. ADR 0009 records a
native WebSocket stack whose event registry was called defineEvents — about
700 lines, never adopted by a single consumer — and ADR 0008 replaced it with
thin Socket.IO wrappers and wrote down the lesson: wrap the transport the
consumers already run on. The name returning does not un-revert that decision;
the declaration is the half that was missing, and it is the only half being
added.
Why the mode is a property of the topic
A delivery mode chosen at the call site is a mode two call sites disagree about. Declaring it means the announcement site cannot decide to stop waiting for listeners on a topic whose listeners were written expecting to be waited for.
Modes describe local delivery only. A remote subscriber cannot delay or veto
a server’s announcement, so nothing about the mode reaches the wire — a
decision topic’s frame is an ordinary announcement once the decision is made.
Three things with no default
whenAllDefer, on adecisiontopic. Every listener deferring is a real outcome — nobody claimed the event — and whichever value were chosen as a default would be a standingallowor a standingdenyapplied to every topic whose author never considered it. Both are decisions; a default makes them silently.listenerTimeoutMs, on any mode that waits. A listener that never settles would otherwise hang the caller forever, and a caller hanging forever is indistinguishable from a caller doing work.- What a non-vote means. A
decisionlistener that throws, times out, or returns something that is not a decision is counted asdenywith a reason. This is the deliberate asymmetry withemit, where an isolated failure means “the others carry on”: for a vote, the same isolation would mean “counted as consent”. A listener that was asked and did not answer has not agreed.
Consequences
- A topic has exactly one name —
prefix.name— and it is the key of the declaration, the event on the wire and the string passed toon. The short key in the literal is where the full name is built, not a second name for it. - A rejected announcement is reported at the peer that refused it, through
onRejected; a fire-and-forget event has no acknowledgement channel to carry a refusal back, which is the boundary ADR 0106 already describes. - Issue paths name the tuple position: a bad
revisionfield is0.revision, because event arguments are a tuple and one payload sits at index 0 of it. - An unsubscribed listener is no longer called mid-dispatch. The bus used to
snapshot its subscription set and call everything in it; on a
decisiontopic that meant a listener which had unsubscribed still got a vote. - The declaration is browser-safe and ships from
stitchkit/live, declared evolving — its shape is being found with its first consumers, and an entrypoint says so where a symbol cannot (→ ADR 0103).