SRD-024 — Event-Based gateway (Exclusive mid-flow deferred choice)¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-06-20 |
| Owner | Ruslan Gabitov |
| Implements | ADR-005 v.4 Gateways & Joins §2.12 |
This SRD lands the Exclusive mid-flow Event-Based gateway decided in ADR-005 v.4 §2.12: a deferred choice (WCP-16) realized as the gate-as-router — one gate track owns all its arms' subscriptions and, on the first event, routes it into the winning arm and advances the token onto that arm's path; the other subscriptions are dropped (no arm tokens, so no withdrawal). Arms = Message/Timer/Signal catch events + Receive Tasks.
Parallel is out of scope — per the spec it is an instantiation construct (start-only, no mid-flow Parallel; ADR-005 v.4 §2.12.3), so it lands with the instantiator follow-up SRD; its semantics is a completion gate (verified §10.6.6/§13.2 — each arm proceeds as its event fires, only completion waits for all). The instantiator forms and Conditional arms are likewise out of scope (§9).
1. Background¶
ADR-005 v.4 §2.12 decides the Event-Based gateway; no implementation exists
(pkg/model/gateways/ has Exclusive/Parallel/Inclusive/Complex, no event gateway).
The gateway is heavily event-coupled, but the machinery it needs already exists:
- Catch-event wait / resume. A track reaching an event node moves to
TrackWaitForEventand registers each definition (internal/instance/track.gosynchronize/run—RegisterEvent(t, eDef)per definition); when the event fires the hub calls the track'sProcessEvent, which delegates tonode.ProcessEventto bind the payload, unregisters the node's definitions, and returns the track toTrackReadysorun()resumes (track.goProcessEvent). The Event-Based gateway reuses this wholesale — it only changes who owns the subscriptions (the gate, for all its arms) and adds a routing step (the fired definition belongs to an arm, not the gate's own node). - Subscription registry.
internal/eventproc/eventhubRegisterEvent/UnregisterEvent/RemoveEventProcessor— register aeventproc.EventProcessor(pkg/eventproc/eventproc.go:18ProcessEvent(context.Context, flow.EventDefinition) error) for a definition; unregister drops it; the hub is the sole waiter owner (ADR-006 v.1 §2.5). - Arm access. A gate reaches its arms structurally:
flow.Node.Outgoing()(pkg/model/flow/node.go:75) → each*SequenceFlow.Target()(sequenceflow.go:290) is an arm node; an arm that is anflow.EventNode(flow/events.go:82) exposesDefinitions() []EventDefinition(the events to subscribe), and implementseventproc.EventProcessorto bind them. - Gateway + validation patterns.
gateways.New(opts)+ per-type wrapper (gateways/exclusive.goNewExclusiveGateway); per-node registration validation via theinterface{ Validate() error }hookProcess.Validateruns (pkg/model/process/process.go:238, added by SRD-023).
The gap. No EventBasedGateway; no gate that subscribes to several arms and
routes by first-fire; the TokenWithdrawn token-state (internal/instance/token.go)
is a mis-model to retire (ADR-005 v.4 §2.12.1 — there are no arm tokens).
2. Requirements¶
Functional¶
- FR-1 —
EventBasedGatewaymodel type. Newpkg/model/gateways/event_based.goEventBasedGatewayembeddingGateway(mirrorsExclusiveGateway,exclusive.go:15), diverging, Exclusive (the only mid-flow configuration — ADR-005 v.4 §2.12.3; Parallel is start-only, deferred).Clone()(fresh per-instance arm state, ADR-009),Node(). - FR-2 — the gate owns all arm subscriptions. The gate reuses the existing
wait-registration path (
track.go:330–349, which gates onnode.(eventproc.EventProcessor)and loops the node'sDefinitions()): theEventBasedGatewayimplementsflow.EventNodewithDefinitions()returning the union of its arms' definitions (gathered fromOutgoing()[i].Target()— each anflow.EventNodeor Receive Task), so when the gate's token arrives the gate track moves toTrackWaitForEventand registers all of them with the gate track as theeventproc.EventProcessor— unchanged registration code. No token is produced on any arm (ADR-005 v.4 §2.12.1). - FR-3 — route on fire. On
ProcessEvent(ctx, eDef)the gate resolveseDef → winning arm node, delegates to that arm'sProcessEvent(ctx, eDef)(the arm binds its own payload — message/item), advances the gate track's step to the arm node, and returns it toTrackReadysorun()resumes into the arm (already satisfied — it does not re-wait) and on to the arm's continuation. - FR-4 — Exclusive policy (first fire wins). The first fire wins: emit one token
onto the winning arm's path and
UnregisterEventevery other arm's definitions. The decision is loop-owned (the loop serializes fires; first processed wins) — no track-side race (NFR-2). - FR-5 — no withdrawal; retire
TokenWithdrawn. Losing arms never received a token, so the gate only drops their subscriptions; theTokenWithdrawntoken-state is removed (internal/instance/token.go:25,28+ itsString()/range guard at:43,:53) along with its projection (internal/instance/observer_test.go:23) (ADR-005 v.4 §2.12.1). - FR-6 — validation (registration).
EventBasedGatewayimplementsValidate() error(theProcess.Validateper-node hook,process.go:238), checking against its now-linked flows (ADR-005 v.4 §2.12.5): (a) ≥2 outgoing arms; (b) every arm is an intermediate Message/Timer/Signal catch event or a Receive Task; (c) each arm has exactly one incoming flow (this gate); (d) noconditionExpressionon the gate's outgoing flows; (e) no boundary events on a Receive-Task arm; (f) a Message catch event and a Receive Task do not coexist (FR-7). - FR-7 — Message-catch / Receive-Task mutual exclusion. A gate with both a Message intermediate catch event arm and a Receive Task arm is rejected at registration — both consume messages, so the routing is ambiguous (BPMN §10.6.6: "If Message Intermediate Events are used … Receive Tasks MUST NOT be used … and vice versa"). Timer/Signal catch arms mix freely with a Receive Task; this ban guards a real ambiguity, so it is enforced, not optional.
- FR-8 — per-instance arm state. The gate's armed/fired bookkeeping (which arm won,
to keep the fire idempotent and the unsubscribe correct) is per-node, per-instance,
created fresh by
Clone()(ADR-009) and mutated under the gateway's own mutex (NFR-2).
Non-functional¶
- NFR-1 — reuse, don't rebuild. No new event subsystem: registration, delivery,
payload binding, and resume are the existing
RegisterEvent/UnregisterEvent/ProcessEvent/TrackWaitForEventpath; the gate adds only multi-arm subscription + routing. - NFR-2 — loop-owned race. The fire/withdraw/complete decision runs on the instance
loop (single writer of track state), as with the synchronizing joins
(ADR-005 §2.4/§2.10/§2.11); a track goroutine never decides the race. Verified under
-race. - NFR-3 — per-instance subscription identity. Point-to-point arm definitions
(Message/Timer) are cloned per instance (
CloneForInstance) so two instances racing the same gate don't cross-fire; signals stay broadcast (ADR-006 §2.1). - NFR-4 — Parallel/OR/Complex untouched. The other gateways keep their contracts; the event-gateway path is additive.
- NFR-5 — coverage. Touched files finish ≥95% diff-coverage (
make cicover-check), aim 100%.
3. Models¶
3.1 EventBasedGateway (pkg/model/gateways/event_based.go)¶
// EventBasedGateway is a diverging Exclusive deferred choice: it subscribes to all its
// arms' events and routes by which fires first; the other subscriptions are dropped. The
// gate owns the wait — no token ever sits on an arm (ADR-005 v.4 §2.12). Parallel is a
// start-only instantiation construct and is out of this SRD's scope (§2.12.3).
type EventBasedGateway struct {
Gateway
}
The gate carries no static policy and no per-instance arm state in this slice: the
winner is decided by the runtime as the events fire (§4.2), and the single-fire guard is
the existing TrackWaitForEvent state plus unregister-all (§10 delta vs FR-8).
3.2 Constructor¶
// NewEventBasedGateway builds a diverging Exclusive Event-Based gateway (just New(opts);
// no gateway-specific options). Arm well-formedness is checked at registration.
func NewEventBasedGateway(opts ...options.Option) (*EventBasedGateway, error)
(WithDirection(Diverging) is inherited from Gateway; a converging Event-Based
gateway is not a BPMN shape. EventGatewayType/WithEventGatewayType arrive with the
Parallel instantiator SRD.)
4. Analysis¶
4.1 The gate as a routing EventProcessor (model vs runtime split)¶
Today track.ProcessEvent (runtime) assumes the fired definition belongs to the
track's current node and calls node.ProcessEvent to bind. For the gate the
current node is the gateway but the fired definition belongs to one of its arm
nodes — so the work splits across the two layers:
- Model layer (
EventBasedGateway).Definitions()returns the arms' union (FR-3, for registration);ProcessEvent(eDef)resolves the owning arm (scanOutgoing()[i].Target()arms'Definitions()) and delegates to that arm'sProcessEvent(eDef)so the arm binds its own payload. The model node has no track and cannot touch runtime state. - Runtime layer (
track.ProcessEvent, extended). When the current node is anEventBasedGateway, after the model routes the binding it advances the track's step to the resolved arm,UnregisterEvents the other arms' definitions, and returns toTrackReady(§4.2).
The resolve + step-advance are the only new logic; binding/resume/unregister are reused.
4.2 After the fire — first wins, drop the rest¶
On the first fire the loop advances the single token onto the winning arm (§4.1) and
UnregisterEvents every other arm's definitions; the per-instance arm state records
that the gate has fired, so a sibling event that was in-flight when its subscription was
dropped is a no-op. One token in, one out; no arm tokens, no withdrawal (FR-5).
4.3 Why the race is loop-owned¶
A fire enters through the hub → the gate track's ProcessEvent. To keep the
first-wins decision and the sibling-unsubscribe free of track-goroutine races (the same
hazard the OR-join/Complex hit, ADR-005 §2.4/§2.10/§2.11), the decision is made on the
instance loop: ProcessEvent records the fire and signals the loop, which performs the
route + (Exclusive) unsubscribe + step-advance as the single writer of track state.
4.4 Validation placement¶
count/structure checks are knowable only after linking, so they run at registration
via the per-node Validate() hook (process.go:238). The gate inspects its
Outgoing() arms (their node types, each arm's Incoming() count, the absence of a
conditionExpression, Receive-Task boundary events, and the §10.6.6
Message-catch / Receive-Task mutual exclusion).
4.5 Retiring TokenWithdrawn¶
internal/instance/token.go's reserved TokenWithdrawn was a placeholder for a
race-loser producer that, per ADR-005 v.4 §2.12.1, does not exist (no arm tokens). It
and any reference are removed; race-losers are pure subscription drops.
5. Test scenarios (§6)¶
| # | Test | Scenario | Asserts |
|---|---|---|---|
| 1 | TestEventGatewayExclusiveFirstWins |
gate → {message arm, timer arm}; fire the message | message arm's path runs, timer arm dropped, instance completes once |
| 2 | TestEventGatewayExclusiveTimerWins |
same; let the timer fire first | timer path runs, message arm unsubscribed |
| 3 | TestEventGatewayReceiveTaskArm |
gate → receive-task arm + signal arm | receive-task path runs on its message |
| 4 | TestEventGatewayRace (-race) |
concurrent fires on two arms | exactly one path runs, no race, no double |
| 5 | TestEventBasedGatewayValidate |
<2 arms / non-arm node / arm with 2 incoming / conditioned arm flow / receive-arm boundary / message-catch + receive-task | each rejected; timer/signal-catch + receive-task accepted |
| 6 | model-unit | NewEventBasedGateway, Clone, ArmFor (signal-by-name) |
construction + arm resolution |
In-package (internal/instance) tests cover the routing for per-package coverage
(cross-package thresher tests don't count — the SRD-022/023 lesson).
8. Cross-doc¶
- Implements ADR-005 v.4 §2.12 (up).
- ADR-006 v.1 §2.1/§2.5 — subscription delivery + sole-hub waiter lifecycle (sideways/up).
- ADR-009 v.1 — per-instance node state
via
Clone(up). - SRD-023 v.1 — the per-node
Validatehook this reuses (sideways).
No downward references; versions pinned.
9. Definition of Done¶
- FR-1…FR-7 wired (FR-8 dropped — §10.4); §5 tests exist and pass under
-race. make cigreen: lint, build,-race, diff-coverage ≥95% (aim 100%), govulncheck.examples/gains an event-based-gateway example (Exclusive deferred choice), smoke exit 0.- ADR-005 v.4 standard-claims verified against the BPMN PDF: the mixing rule (§10.6.6 — only Message-catch + Receive-Task is forbidden) and the Parallel completion-gate (§10.6.6 / §13.2).
- Out of scope (deferred, ADR-005 v.4 §2.12.7): the Parallel configuration (start-only — completion-gate semantics, verified) and both instantiators (Exclusive-start, Parallel-start — born-from-event + correlation), all in a follow-up SRD; Conditional arms (need a conditional waiter); loop re-arming (engine-wide, §4).
10. Implementation summary¶
Landed on feat/event-based-gateway (off master): the doc + three milestones.
10.1 Commits¶
| M | Commit | Scope |
|---|---|---|
| doc | 1524c13 |
SRD-024 (this doc) |
| M1 | b8b93ad |
EventBasedGateway model + options + Validate + model-unit tests |
| M2 | 97030f0 |
runtime routing (track.ProcessEvent→advanceToArm); TokenWithdrawn retired |
| M3 | adf39c4 |
thresher tests + example; the defMatches signal-routing fix |
ADR-005 v.4 (§2.12) is the decision; 6af7c0f.
10.2 Key files¶
pkg/model/gateways/event_based.go—EventBasedGateway,Definitions/EventClass(flow.EventNode),ArmFor/defMatches/ProcessEvent(eventproc.EventProcessor),Exec(fails loudly),Validate.internal/instance/track.go— theeventRouterinterface +advanceToArm, and theProcessEventgate branch.internal/instance/token.go+pkg/thresher/handle.go—TokenWithdrawnremoved.examples/event-based-gateway/.
10.3 Verification¶
make cigreen: lint, build,-race, diff-coverage 99.6% (≥95), govulncheck.event_based.go100% (model-unit, external + in-package);advanceToArm100%.- Tests: model-unit, in-package routing (
-race), thresher deferred-choice (signal first/second-wins + concurrent) and the receive-task arm via the broker. - All 14
examples/smoke exit 0.
10.4 Deltas vs the draft¶
- Mixing rule corrected by the PDF check (post-draft rework). The draft and the M1
code rejected any catch-event + Receive-Task mix and parametrized it
(
WithMixedArms). The BPMN PDF (§10.6.6, not §13.4.4) only forbids a Message intermediate event coexisting with a Receive Task; Timer/Signal + Receive Task is allowed. ReworkedValidateto the §10.6.6 rule and removedWithMixedArms/allowMixed(its relaxation premise was a misreading). The same verification confirmed Parallel is a completion gate, not a barrier (§2.12.3). This is exactly the class of error the standard-claim verification exists to catch. defMatches(M3 — a real fix).ArmForfirst matched the fired event by id, but a broadcast Signal is delivered as the thrower's definition (the EventHub routes signals by name, not id), so a signal arm never resolved and the gate parked forever.defMatchesnow matches point-to-point triggers (Message/Timer) by id and Signals by name. The earlier milestones only fired signals via the arm's own definition and messages point-to-point, so the broadcast path wasn't exercised until the thresher signal test.- FR-8 dropped. Per-instance fired-state is unnecessary —
ProcessEvent's existingTrackWaitForEventguard (a second, in-flight event finds the track alreadyReady→ rejected) plus the unregister-all give single-fire for free. TokenWithdrawnretirement wider than §1/FR-5 listed. Also the publicthresher.TokenStatevalue + thehandle.gomapping + both mapping tests, not justtoken.go/observer_test.go.advanceToArmreturns void, not error. ItsArmFor-miss is unreachable — the gate'sProcessEventresolved+bound the arm just before — so the error path was dead; a hypothetical miss degrades safely (no step appended → loop re-enters the gate →gate.Execfails loudly).- Receive-task arm tested at thresher level (M3), not in-package (M2). A message
isn't deliverable through the in-package
PropagateEvent(signal-broadcast only); it needs the broker, so that arm is covered by the thresher test.
Open questions¶
- None.