SRD-048 — Conditional events: catch, boundary, and event-based-gateway arms¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-15 |
| Owner | Ruslan Gabitov |
| Implements | ADR-006 v.3 §2.7 (conditional events — status-based triggering by commit-diff); GitHub issue #89 |
| Upstream | ADR-011 v.6 §2.9.4 (the commit-diff change signal this rides), ADR-018 v.1 §2.7 (the boundary-trigger deferral this closes for Conditional), ADR-005 v.4 (event-based-gateway arms), ADR-001 v.6 (the single-writer loop the registry lives on), ADR-013 v.2 (fact kinds the new signals reuse) |
| Refines | — |
Note on numbering: SRD-047 is reserved for the structural-data maps slice (S5); this workstream takes 048.
§1 Background¶
The model layer already carries a complete ConditionalEventDefinition
(pkg/model/events/conditional.go:11-63: non-nil-condition constructor,
Type() == flow.TriggerConditional, Condition() getter), and two positions
already accept it — startTriggers (start.go:21) and
intermediateCatchTriggers (intermediate_catch.go:19) — while nothing
executes it: the hub waiter factory has no Conditional case and rejects it
with ObjectNotFound (internal/eventproc/eventhub/waiters/waiters.go:54-81),
and no runtime path evaluates a condition. A modeller can build a process with
a conditional catch that then hangs forever — the silent-misbehavior class
SRD-046 §1 fixed for flow conditions, now at the event layer.
ADR-006 v.3 §2.7 settles the conception this SRD lands:
- In scope: intermediate catch, boundary (interrupting and non-interrupting), and event-based-gateway arms.
- Not supported: top-level conditional START — BPMN Table 10.84 forbids
the start condition to reference process data, and gobpm exposes no legal
static-attribute surface; it becomes a fail-fast placement rejection
in
Process.Validate(registration time — construction stays legal for the future event-sub-process reuse). Conditional start returns for event sub-processes only, with the Sub-Process workstream, where §10.4.3 legally scopes the condition to the enclosing instance. - Trigger source is the instance's own data commits, so ownership is
loop-local (per-instance, single-writer) — the hub is never involved.
The substrate exists:
Frame.Commit()returns the committed changed-path set[]data.Change(internal/scope/frame.go:251-283, SRD-044), consumed today only bytrack.reportDataChanges(internal/instance/track.go:964-983,datachange.go:18-35). - Firing follows the normative false→true edge rule (Table 10.84); arm-time evaluation fires immediately when the condition is already true.
- Granularity is one uniform per-subscription rule with no processing
modes: an expression may carry an optional
Dependencies() []stringstatement — absent/nil re-evaluates on every non-empty commit (the safe fallback), non-empty re-evaluates only when the commit-diff intersects the declared paths (segment-prefix match), explicitly-empty is rejected at construction. - Multi-fire ordering: one commit → one evaluation sweep in arming order; a disarming fire voids later-collected deliveries.
§2 Requirements¶
Functional¶
- FR-1 —
data.DependencyListercapability. New optional interface inpkg/model/data(new filedependencies.go, one entity per file):Dependencies() []string— the data paths (structural grammar,path.go) the expression reads. Absent capability or a nil return means "may read anything" → always re-evaluate. No process/engine-level mode exists. - FR-2 —
goexpr.WithDependencies. New functional option ongoexpr.New/Mustdeclaring the expression's read paths. Validation: at least one path, each non-empty and parseable by the structural grammar (data.SplitPath); an explicitly-empty call is rejected ("depends on nothing" would mean never re-evaluate — the degenerate trap).GExpressionstores the list and implementsDependencyLister. The option is a goexpr-local option type carrying theoptions.Optionmarker (theevents.EventOptionpattern), dispatched by type-switch inNew; non-goexpr options continue todata.NewExpressionunchanged. The option is a self-naming closure returningerrserrors (project option rule). - FR-3 — Boolean condition enforced at construction.
NewConditionalEventDefinition(conditional.go:23) additionally rejects a condition whoseResultType() != "bool"— the SRD-046checkConditionprecondition (flowselect.go:85-90) moved to model-build time, so a non-bool condition never reaches the runtime. - FR-4 — Top-level conditional START fail-fast, at the placement seam.
The StartEvent construction surface stays legal —
TriggerConditionalremains instartTriggers(start.go:21) with the comment flipped to "only for event Sub-Processes" (the existing pattern: Compensation / Error / Escalation sit in the same set marked "only for in-line Sub-Processes",start.go:20-25), andstartConfig.setCondition(start_options.go:154-159) keeps appending — because construction is context-free and the same StartEvent type is the future event-sub-process start. The rejection moves toProcess.Validate(process/process.go:220), the only context-aware seam: aProcessis the top-level container, so a direct node that is a Start Event (flow.EventNodewith the Start class) carrying a Conditional definition is a classified error (message: not supported on a top-level Start Event per BPMN Table 10.84 — the start condition may not reference process data; conditional start arrives with event sub-processes).Validateruns at registration (snapshot.New), so this is still fail-fast before any snapshot or instance — and it needs no rework when event sub-processes land: their start events are not direct nodes of the top-level Process. The per-node self-validation hook already inValidate(process.go:245-254, the Complex-gateway precedent) is NOT used — a node-ownedValidate()would be context-free again. - FR-5 — Boundary position opens. Add
flow.TriggerConditionaltoboundaryTriggers(boundary.go:14-19) and update the deferral comment (Conditional leaves the ADR-018 v.1 §2.7 deferred set). - FR-6 — Event-based-gateway arms open. The arm validation
(
gateways/event_based.go:427-442) acceptsflow.TriggerConditionalalongside Message/Timer/Signal; the rejection message atevent_based.go:435gains/Conditional. - FR-7 — Conditional defs bypass the hub. In the catch registration path
(
track.gocheckNodeType, lines 413-465)TriggerConditionaldefinitions are not passed toRegisterEvent; they are carried to the loop on theevWaitingemit (newtrackEventfieldcondDefs []*events.ConditionalEventDefinition, sibling ofmsgDefIDs) and on the spawn path for a track that parks before the loop drains (themsgIdxseeding precedent,loop.go:306-320). The waiter factory stays untouched — noConditionalWaiterexists, by design. - FR-8 — Loop-local armed registry.
loopStategainsconds []*condWatch— a slice, because the multi-fire ordering contract is arming order (a map would lose it). Each entry records the flavor (catch / boundary), the parked or host track, the node, the definition, the dependency paths (extracted once at arm via theDependencyListerassert), and the last observed value (edge state). Entries are torn down when their track flips out of waiting, moves, ends, or fails — theclearMsgIdx/disarmBoundarieslifecycle (loop.go:365-373,boundary_watch.go:122-150). - FR-9 — Arm-time evaluation. On arming (catch: the
evWaiting/spawn handling; boundary:armBoundaries), the loop evaluates the condition once: true → fire immediately (ADR-006 v.3 §2.7); false → recordlast=falseand wait for commits. - FR-10 — Commit signal to the loop. New
trackEventkindevDataCommitcarrying the committed[]data.Changeand the committing node.finalizeNodeExecutionemits it after a successful non-empty commit — but only when the process can have armed conditionals:snapshot.Snapshotprecomputes aHasConditionalsflag in one node pass at build (theInstantiatingStartsprecedent,snapshot.go:16-36), so a conditional-free process never pays the emit (NFR-1). - FR-11 — The uniform re-evaluation rule. On
evDataCommit, the loop sweepscondsin arming order; for each armed entry: skip when its dependency list is non-empty and does not intersect the commit's changed paths (segment-prefix overlap, FR-12); otherwise evaluate. Fire on the false→true edge only:last==false && now==true→ fire;now==false→ re-arm (last=false); already-true without an edge → no fire. Fires collected in the sweep are applied in arming order; a disarming fire (an interrupting boundary cancelling its host, a catch flipping its track out of waiting) voids later-collected fires whose entry it tore down. - FR-12 —
data.PathsOverlap. New helper beside the path grammar (pkg/model/data/path.go): two paths overlap when equal or when one is a segment-boundary prefix of the other (order↔order.totaloverlap;order↔ordersdo not). Used by FR-11; exported — it is the public semantics of a dependency declaration. - FR-13 — Evaluation idiom. Loop-side evaluation uses the
authorizeTaskprecedent (tasks.go:186-199): open a transient root frame (inst.sc.openFrame), wrap innewExecEnv(inst, frame)as thedata.Source, evaluate viainst.ExpressionEngine().Evaluate(ctx, cond, env),Discardthe frame. An evaluation error is an instance failure (inst.fail+stopAll— thearmBoundariesregistration-failure handling,boundary_watch.go:90-101): a condition the engine cannot evaluate leaves the model's declared wait meaningless, the fail-fast class (never log-and-continue). - FR-14 — Catch fire delivery. A firing catch/EBG-arm conditional
delivers through the existing parked-dispatch contract
(
dispatchToParkedsemantics,loop.go:333-355): the track must still be parked-and-undelivered,flipNotParkedmakes deferred choice atomic (a conditional arm losing to a Message/Timer/Signal arm — and vice versa — is a benign drop), the definition goes to the track's bufferedevtCh. - FR-15 — Boundary fire delivery. An interrupting conditional
boundary fires through the
fireBoundaryarbitration (theevBoundaryhandling — host cancel + exception-flow continuation, SRD-029 semantics); already on the loop goroutine, it calls the handler directly instead of re-emitting to its own channel. A non-interrupting one spawns the boundary's outgoing track without cancelling the host and re-arms withlast=true— a re-fire needs a fresh false→true edge. - FR-16 — Observability. No new kind. Catch-flavor arm/fire/drop emit
KindEventFlowfacts (PhaseRegistered/PhaseFired/PhaseDropped,fact.go:23,50,69-71); boundary-flavor arm/fire/disarm emitKindBoundary(PhaseArmed/PhaseFired/PhaseDisarmed) — thearmBoundaries/disarmBoundariesfact shape (boundary_watch.go:105-113,140-146). - FR-17 — Front-door sync. Runnable example
examples/conditional-events/(entry >80 lines → split by concern, the examples rule),CHANGELOG.md[Unreleased]entry, thedocs/design/conformance-status.mdrow flip (§2 row 1 → §1, same-PR tracker rule),examples/README.md+ rootREADME.md(and RU twin) feature-list refresh.
Non-functional¶
- NFR-1 — Zero cost without conditionals. A process with no
ConditionalEventDefinitionanywhere emits noevDataCommit(theHasConditionalsstatic gate, FR-10) and allocates no registry. - NFR-2 — Single-writer preserved. All conditional state
(
conds, edge flags) isloopState-owned, mutated only on the loop goroutine, no locks (ADR-001 v.6,loop.go:14-18). Scope reads during evaluation are Scope-serialized (frame.go:24-26), safe from the loop. - NFR-3 — Hub untouched. No waiter type, no
CreateWaitercase, no hub registration for Conditional — the trigger's source is the instance itself (ADR-006 v.3 §2.7). - NFR-4 — Camunda-aligned default. No dependency statement →
re-evaluate on every non-empty commit; a declared list is the
variableName-style narrowing. Deviation-free: a missing statement costs performance, never correctness. - NFR-5 — Coverage. Touched files land at 100% (min 80%) with the
change; diff-coverage gate ≥95% (
make ci).
§3 Models¶
§3.1 data.DependencyLister (new: pkg/model/data/dependencies.go)¶
// DependencyLister is the optional capability of a FormalExpression to
// declare the data paths it reads (ADR-006 v.3 §2.7). A nil/absent list
// means the expression may read anything — its conditional subscription
// re-evaluates on every non-empty commit; a non-empty list narrows
// re-evaluation to commits whose diff overlaps a declared path
// (PathsOverlap). An expression never declares an empty non-nil list —
// constructors reject it.
type DependencyLister interface {
Dependencies() []string
}
§3.2 goexpr option (new: pkg/model/data/goexpr/options.go)¶
// GExpOption configures a GExpression at construction.
type GExpOption func(g *GExpression) error
// Option marks GExpOption as an options.Option.
func (GExpOption) Option() {}
// WithDependencies declares the data paths the expression's gfunc reads
// (data.DependencyLister). At least one path is required — an empty
// declaration would mean "never re-evaluate", the degenerate trap — and
// every path must parse under the structural grammar.
func WithDependencies(paths ...string) GExpOption {
return GExpOption(func(g *GExpression) error {
if len(paths) == 0 {
return errs.New(
errs.M("WithDependencies: at least one path is required"),
errs.C(errorClass, errs.InvalidParameter))
}
// per-path: non-empty + data.SplitPath parses; errors name the
// option and the offending path (self-identifying message rule).
...
g.deps = append(g.deps, paths...)
return nil
})
}
GExpression gains deps []string and
// Dependencies implements data.DependencyLister: the paths declared via
// WithDependencies, or nil when the expression declared nothing (= may
// read anything, re-evaluate on every non-empty commit).
func (ge *GExpression) Dependencies() []string { return ge.deps }
goexpr.New type-switches its opts: GExpOption applies locally, all
other options forward to data.NewExpression as today
(goexpr.go:62-71).
§3.3 condWatch (new: internal/instance/conditional.go)¶
// condWatch is one armed conditional subscription, loop-owned (ADR-006
// v.3 §2.7). Flavor catch: track is the parked catch/EBG track and node
// its event node. Flavor boundary: track is the HOST activity track, node
// the boundary event node, interrupting per the boundary's model flag.
type condWatch struct {
track *track
node flow.Node
def *events.ConditionalEventDefinition
deps []string // extracted once at arm (DependencyLister)
last bool // edge state: last observed condition value
boundary bool
interrupting bool
}
loopState gains conds []*condWatch (slice — arming order is the
multi-fire contract, FR-8/FR-11).
§3.4 trackEvent deltas (internal/instance/event.go)¶
- New field
condDefs []*events.ConditionalEventDefinition— carried onevWaiting(sibling ofmsgDefIDs, same emit;track.go:437-443). - New field
changes []data.Change— carried on the new kind. - New kind
evDataCommit(+ itstrackEventKindNamesrow): a track's frame commit produced a non-empty changed-path set; the loop sweeps the armed conditionals (FR-11). Emitted fromfinalizeNodeExecutionafterf.Commit()succeeds, gated onlen(changes) > 0 && snapshot.HasConditionals.
§3.5 snapshot.Snapshot delta¶
HasConditionals bool — precomputed by New in the same node pass style
as InstantiatingStarts (snapshot.go:29-35): true iff any node's event
definitions include a TriggerConditional. Immutable, shared by Clone.
§4 Analysis¶
§4.1 Why loop-local, not a hub waiter¶
The hub distributes external occurrences (messages, timers, signals)
to registered waiters. A conditional trigger's occurrence source is the
instance's own committed data — the loop already owns both the commit
signal (it receives every track's events) and the subscription lifecycle
maps (waiting, watchers). Registering the instance's own state change
into a global hub and routing it back would add two goroutine hops, a
correlation surface, and a teardown protocol for zero reach gain (no other
instance may observe this instance's data — ADR-010 scope isolation).
Rejected alternative: a ConditionalWaiter polling scope — polling is the
exact anti-pattern ADR-006 v.3 §3 names ("data-driven waiting is
expressible without polling").
§4.2 Why a new event kind, not piggybacking reportDataChanges¶
reportDataChanges emits observability facts — observer-plane,
fire-and-forget, explicitly not a control signal (ADR-013 v.2 §2.2
separation). Routing engine semantics through the observer plane would
make correctness depend on an observer being attached. The control-plane
lane for track→loop signals is trackEvent (event.go:5-43); a new kind
is the established extension point (13 kinds today, each one landing).
§4.3 Sweep semantics — one commit, one snapshot, arming order¶
The loop evaluates all due conditionals in one evDataCommit application
(apply, loop.go:225-304, runs to completion before the next event).
Evaluations read the current committed scope — a concurrent commit by
another track may already be visible; that is correct for status-based
semantics (the condition is about state, not about the triggering event's
payload — ADR-006 v.3 §2.7) and its own evDataCommit follows in FIFO
order anyway, so no edge is lost. Fires are collected during the sweep and
applied in arming order; applying a disarming fire tears its entries out
of conds, and later-collected fires whose entry is gone are voided —
exactly the ADR's multi-fire rule.
flowchart TD
C[track: Frame.Commit non-empty] -->|"evDataCommit(changes)"| L[loop: sweep conds in arming order]
L --> D{deps nil OR<br/>PathsOverlap with changes?}
D -- no --> SKIP[skip entry]
D -- yes --> EV[evaluate condition<br/>transient frame + ExpressionEngine]
EV --> E{"edge: last==false && now==true?"}
E -- "now==false" --> RA[re-arm: last=false]
E -- no edge --> KEEP[keep last=true]
E -- yes --> F[collect fire]
F --> APPLY[apply fires in arming order;<br/>a disarming fire voids later ones]
§4.4 Edge state at re-arm (non-interrupting boundary)¶
After a non-interrupting fire the entry stays armed with last=true: the
next commit that evaluates true produces no edge; a commit evaluating
false re-arms (last=false); the subsequent true fires again. This is
Table 10.84's re-fire semantics verbatim (the vendored appendix,
docs/bpmn-spec/semantics/event-handling.md).
A boundary event carrying a Conditional definition beside another
definition needs no special casing: armBoundaries already arms each
definition independently (the per-def loop, boundary_watch.go:79-116 —
OR semantics), so a conditional def is just one more independent watch;
combined AND semantics would be a parallel-multiple event, which stays
deferred.
§4.5 Rejected: process-level processing modes¶
An earlier design draft carried CondReevaluateAll/CondFiltered
process modes with registration-time completeness validation. Deleted: the
uniform rule makes a missing declaration fail-safe (always
re-evaluate — correct, just unfiltered), so the mode guarded a condition
that is already safe; the runtime behaviour of any mode combination is
byte-identical to the per-expression rule. A wrong non-empty
declaration remains the author's contract, declared beside the
expression's logic — the same ownership Camunda assigns variableName.
§4.6 Rejected: runtime read-tracing¶
Inferring the dependency set by tracing a GExpFunc's reads is unsound:
the functor takes data-dependent paths, and a closure can read a captured
live wrapped struct without touching ds.Find at all — a silently
incomplete trace means a missed wake-up, the worst failure mode. Exact
sets come only from derivation (introspectable declarative
expressions, the expression-layer workstream #74; a conservative codegen
source analyzer later), both failing toward re-evaluation.
§5 API surface¶
Public: data.DependencyLister, data.PathsOverlap,
goexpr.WithDependencies (+ GExpression.Dependencies), the tightened
events.NewConditionalEventDefinition (bool-typed condition required),
the widened boundaryTriggers/EBG-arm acceptance, and the
Process.Validate placement gate (a top-level conditional Start is
rejected at registration; construction stays legal — it is the future
event-sub-process surface).
Everything else is internal/instance machinery.
§6 Test scenarios¶
Model (M1/M2):
TestDependencyLister(pkg/model/data) — capability assert on an implementing/non-implementing expression.TestPathsOverlap— table: equal, prefix both directions, sibling (ordervsorders) no-overlap, indexed steps.TestWithDependencies(goexpr) — declared list surfaces viaDependencies(); empty call rejected; empty/unparseable path rejected; plain construction returns nil.TestConditionalDefinitionBoolGate— non-bool condition rejected atNewConditionalEventDefinition.TestStartConditionalRejected(pkg/model/process) — a process whose Start Event carries a Conditional definition failsValidate()with the classified placement error; today's all-triggers accept-assert (start_test.go:163-198) stays — construction remains legal.TestBoundaryConditionalAccepted,TestEBGConditionalArmAccepted— allow-list/arm-validation flips.
Runtime (M3/M4, internal/instance):
TestConditionalArmTimeFire— condition already true at park → fires immediately, no commit needed.TestConditionalEdgeRule— false at arm; commit→true fires; staying true across commits does not re-fire; false then true re-fires (non-interrupting boundary flavor).TestConditionalDependencyFiltering— declared["order"]: commit touchingcustomerskips evaluation (counted via an evaluation-counter expression), commit touchingorder.totalevaluates; undeclared expression evaluates on both.TestConditionalMultiFireOrder— two conditionals turning true on one commit fire in arming order; an interrupting-boundary fire voids the later-collected fire of the same host.TestConditionalBoundaryInterrupting/NonInterrupting— host cancel + exception flow; parallel spawn + re-arm.TestConditionalLosingArm— EBG with Conditional + Signal arms: the signal fires first, the conditional's later fire drops (flip contract).TestConditionalEvalFailureFailsInstance— an erroring condition → instance failure (FR-13).TestNoConditionalsNoCommitSignal—HasConditionals=falseprocess emits noevDataCommit(NFR-1).
E2E (M5, pkg/thresher): TestConditionalEventsE2E — a process where a
service task's committed output flips an intermediate-catch condition and
a boundary condition; asserts both paths complete and the KindEventFlow/
KindBoundary facts appear.
§7 Milestones¶
| # | Scope | Commit shape |
|---|---|---|
| M1 | data.DependencyLister + data.PathsOverlap + goexpr.WithDependencies/Dependencies + bool gate in NewConditionalEventDefinition + tests |
feat(data,goexpr): dependency statements for conditional expressions |
| M2 | Process.Validate top-level placement gate + startTriggers comment + boundary allow-list + EBG arm acceptance + tests |
feat(events,gateways,process): open conditional positions, gate top-level start |
| M3 | Snapshot.HasConditionals + evDataCommit + condWatch registry + catch flavor (arm/evaluate/edge/deliver/teardown) + tests |
feat(instance): loop-local conditional subscriptions (catch) |
| M4 | boundary flavor (interrupting + non-interrupting) + EBG-arm e2e in-package tests | feat(instance): conditional boundary events |
| M5 | thresher e2e + examples/conditional-events/ + changelog + conformance-tracker row + README/examples sync |
feat: conditional events — e2e, example, front-door sync |
Post-M5: /check-srd, §10 fill, SRD status flip, ADR-006 v.3 → Accepted +
RU twin refresh, sync linked docs (ADR-018 §2.7 deferral annotation,
ADR-005 arm note).
§8 Cross-doc¶
- Implements ADR-006 v.3 §2.7 — every §2 requirement realizes a §2.7 clause (traced inline above).
- Closes the Conditional row of ADR-018 v.1 §2.7's deferred boundary triggers.
- Rides ADR-011 v.6 §2.9.4 commit-diff and ADR-005 v.4 event-based-gateway semantics; preserves ADR-001 v.6 single-writer.
- Observability shapes per ADR-013 v.2 (no new kind).
§9 Definition of Done¶
- [ ] All FR/NFR wired and traced to §6 tests.
- [ ]
make cigreen (lint 0, race tests, diff-coverage ≥95%, vuln 0); touched files at 100% (min 80%). - [ ] Example runs to completion under timeout (exit 0), binary gitignored.
- [ ] Conformance tracker row 1 flipped to §1 in this PR.
- [ ] Changelog
[Unreleased]updated before the PR description. - [ ]
/check-srdPASS; §10 filled; ADR-006 flipped Accepted + RU twin refreshed; linked docs synced. - [ ] Issue #89 closable (top-level START explicitly out per ADR-006 v.3; noted in the closing comment).
§10 Implementation summary¶
§10.1 Milestones by commit (branch feat/conditional-events)¶
| Stage | Commit | Scope | Tests |
|---|---|---|---|
| doc | 39f2bef |
SRD-048 + the ADR-006 §2.7 wording alignment ("rejected at model validation") | — |
| M1 | 9e853e8 |
data.DependencyLister + data.PathsOverlap + goexpr.WithDependencies/Dependencies() + the bool gate |
TestPathsOverlap, TestWithDependencies, TestConditionalDefinitionBoolGate |
| M2 | 986ec83 |
Process.Validate placement gate + startTriggers comment + boundary allow-list + EBG arms |
TestStartConditionalRejected, boundary rows, the flipped EBG accept case |
| M3 | 46d3454 |
Snapshot.HasConditionals (+ Clone copy) + evDataCommit + condWatch registry + the catch flavor |
TestCondDue, ArmTimeFire, EdgeRule, DependencyFiltering, MultiFireVoiding, eval-failure cases, emit gates, snapshot flags |
| M4 | 75a05e3 |
Boundary flavor (loop-owned boundaryWatch, interrupting + non-interrupting) + EBG-arm tests + the spawn cancel-order fix |
TestConditionalBoundary{ArmTimeInterrupting, InterruptingSweep, NonInterrupting, ArmEvalFailure}, TestEBGConditionalArm{Wins, Loses} |
| M5 | f0dc475 |
Thresher e2e + examples/conditional-events/ + guide + changelog + tracker row + README sync + the fork-into-catch deadlock fix |
TestConditionalEventsE2E |
Every milestone landed with make ci green; final diff-coverage 97.9% of
379 changed lines (min 95%).
§10.2 Deltas vs the draft¶
- FR-4 was redesigned during the doc review (before approval, reflected
above): the rejection moved from the StartEvent construction surface to
the
Process.Validateplacement seam, keeping construction legal for the future event-sub-process start (owner's catch). - §3.3
condWatchshape: the draft carriedboundary bool + interrupting bool; landed withboundary boolonly — the interrupting discrimination lives where it always did, infireBoundaryreading the model'sCancelActivity(), so duplicating it on the watch was dropped. - Boundary lifecycle reuse: instead of a parallel conditional-boundary
lifecycle, a conditional def gets a regular
boundaryWatchentry markedloopOwned(no hub registration; disarm skips the hub unregister), soarmedForand the disarm path govern both flavors unchanged. clearCondsis keyed by trackID, not the pointer — the disarm sites carry the id.
§10.3 Empirical findings¶
- Fork-into-catch loop deadlock (fixed in M5). Building a fork-born
track directly on a catch node emitted
evWaitingfrom the loop goroutine (spawnForks → newTrack → checkNodeType), deadlocking the loop on its own channel; pre-existing for every catch trigger, first exposed by this SRD's e2e process shape. Fixed by anatConstructionflag: construction never emits —spawn'srecordBornWaiterrecords born-parked tracks; only the mid-run path (the track's own goroutine) emits. Snapshot.Clonesilently dropped the new flag. The firstHasConditionalsimplementation set the field inNewbut not inClone's literal — and the instance runs on the clone, so the commit signal was silently disabled (a hung test caught it). Pinned byTestSnapshotHasConditionalsasserting the flag on the clone.
§10.4 Backlog (out of SRD-048 scope)¶
- Fork-born Message catch with a broker-buffered message: the
synchronous waiter fire at registration (
MessageWaiterdraining on RegisterEvent) can also run on the loop goroutine for a fork-born track — the evDeliver sibling of the fixed evWaiting deadlock. Needs its own FIX (likely: move fork-born hub registration to the spawn path).
Open questions¶
None.