Skip to content

SRD-089.D — BPMN import: typed events and the catalogs they refer to

Field Value
Status Accepted
Date 2026-08-11
Owner Ruslan Gabitov
Implements ADR-024 v.4 §2.9 (the element set), §2.15 (the report contract this stage exercises)
Upstream ADR-024 §2.16 (which refusals are staged, capability-blocked or standing — §4.6 and §4.11 are instances of it), ADR-006 v.6 (event definitions and subscriptions), ADR-014 v.2 (messages), ADR-018 v.1 (boundary attachment), ADR-026 v.1 (compensation)
Related SRD-089.A (the dispatch tables and deferred references this extends), SRD-089.B (the expression language a timer and a condition are written in), SRD-089.C (the flow nodes this attaches events to)
Tracking #284 — Part of

Every element .C left refused was refused for one reason: its constructor needs an event definition, and an event definition needs a catalog object the importer does not build. This stage builds them. It is the largest single widening in the slice — a plain start event becomes a message, timer, signal, error, escalation or conditional start event; an activity gains a boundary; and send and receive tasks stop being holes in the task family.


§1 Background (verified)

Ten event definitions exist, and none of them is missing from the model: NewMessageEventDefinition (pkg/model/events/message.go:29), NewTimerEventDefinition (timer.go:45), NewSignalEventDefinition (signal.go:71), NewErrorEventDefinition (error.go:27), NewEscalationEventDefinition (escalation.go:103), NewConditionalEventDefinition (conditional.go:24), NewTerminateEventDefinition (terminate.go:20), NewCancelEventDefinition (cancel.go:31), NewCompensationEventDefinition (compensation.go:35) and NewLinkEventDefinition (link.go:26). What blocks the importer is not the definitions but what four of them require.

Four require a catalog object. Message needs a *bpmncommon.Message, signal a *events.Signal, error a *bpmncommon.Error, escalation a *events.Escalation — each rejecting nil. Those are <bpmn:message>, <bpmn:signal>, <bpmn:error> and <bpmn:escalation>, definitions-level root elements the importer currently refuses.

Two take an expression, and the model already knows how to feed both. NewConditionalEventDefinition(condition data.FormalExpression, …) rejects a nil condition and any expression whose ResultType() is not "bool" (conditional.go:28, :39-46) — which is exactly what .B's lite.Cond declares (lite/lite.go:185-191). NewTimerEventDefinition is stricter still, checking each expression's declared result type against a fixed table (timer.go:75-92), and the text expression language declares none of int or Duration. That is not the constructor an importer should be calling: NewISO8601Timer(s string, …) takes ONE ISO 8601 string and disassembles it into those attributes itself (timer_iso8601.go:43-90), and NewISO8601TimerExpr(form, e, …) does the same for a value that is an expression, adapting its result to the type required. §4.6 records the mapping.

One thing .B built is flow-shaped and must be widened. newCondition(fs flowSpec, docLang string) (language.go:168) reads fs.condLang, fs.condBody, fs.condID and fs.id — it is written for a sequence flow. A conditional event definition carries a body and no flowSpec, so M2 first extracts the body-level half (resolve language → translate JUEL → mint) and re-expresses newCondition on top of it. That is a refactor inside landed .B code, not a new table row, and §7 says so.

One requires a resolved node. NewCompensationEventDefinition(activity flow.ActivityNode, waitForCompletion bool, …) takes the activity positionally and does not reject nil — which matches the standard, where a compensation event without an activityRef compensates its whole scope.

Two constructor families disagree about how a definition arrives. NewIntermediateCatchEvent(name, def flow.EventDefinition, …) and NewIntermediateThrowEvent take it positionally and reject nil (intermediate_catch.go:43, intermediate_throw.go:49). NewStartEvent(name, opts …) and NewEndEvent(name, opts …) take only options, and receive definitions through the eight With*Trigger options in event_options.go:50-136 plus WithTerminateTrigger in end_options.go:95.

There is no WithLinkTrigger, and there should not be. The extract's boundary matrix lists Link as "NO (only in normal flow)" (semantics/event-handling.md:67), and the standard confines a link event to the intermediate position — which is the one place the model takes a definition positionally and therefore accepts one.

Boundary events need three things, all available: NewBoundaryEvent(name, host flow.ActivityNode, def flow.EventDefinition, cancelActivity bool, …) (boundary.go:66). The host is attachedToRef — a reference resolved after the graph is built, which is precisely what .A's deferred-reference machinery does. cancelActivity defaults to True (elements/events.md:252).

Send and receive tasks need only a message. NewSendTask(name, msg *bpmncommon.Message, …) (send_task.go:29) and NewReceiveTask (receive_task.go:58). Once the catalog exists they cost a table row each.

Two mismatches between the standard and the constructors shape §4. The extract gives Message.itemRef, Signal.structureRef, Error.structureRef and Escalation.structureRef cardinality 0..1, and name 0..1 for all four (elements/event-definitions.md:230-318). The model is stricter: NewMessage rejects a nil item (bpmncommon/message.go:41), NewEscalation rejects a nil item (escalation.go:38), and all four reject an empty name. A spec-legal <bpmn:message id="m1"/> is unconstructible as written.

§2 Requirements

Functional

  • FR-1 — The four catalogs import. <message>, <signal>, <error> and <escalation> at definitions level become model objects, indexed by id for the references that follow. errorCode and escalationCode are carried.
  • FR-2 — All ten event definitions import, each from its own <*EventDefinition> child, in every event position the model accepts it. A timer imports in all three of its forms, literal or expression (§4.6).
  • FR-3 — A start or end event carrying a definition imports through the matching With*Trigger option, including parallelMultiple and the interrupting/non-interrupting distinction the model already models.
  • FR-4 — <intermediateCatchEvent> and <intermediateThrowEvent> import, with their definition supplied positionally. An intermediate event with no definition is refused, naming the constructor's requirement — the standard has no untyped intermediate event either.
  • FR-5 — <boundaryEvent> imports, its attachedToRef resolved through .A's deferred references and its cancelActivity defaulting to true. Attaching to a non-activity, or to an id that does not exist, is an error that names both ends.
  • FR-6 — <sendTask> and <receiveTask> import, resolving messageRef against the catalog. .C's refusal of both is removed.
  • FR-7 — A reference to a catalog object that does not exist is an error, not a silent nil — messageRef, signalRef, errorRef, escalationRef, activityRef and attachedToRef all resolve or fail by name.
  • FR-8 — What the model cannot hold is reported, not dropped. A catalog object's itemRef/structureRef is not imported (§4.1), and a messaging task's implementation/operationRef have nowhere to land. Each occurrence adds a Dropped entry naming the element and the reason.

Non-functional

  • NFR-1 — Position rules stay the model's. The converter does not re-implement the boundary trigger matrix (event-handling.md:54-67); it hands the definition to the constructor and reports the constructor's refusal. Two copies of a rule diverge, and the model's copy is the one the runtime obeys.
  • NFR-2 — make ci green, cover-check at COVER_MIN, judged from .ci/last-run.json.
  • NFR-3 — The stage adds no new disposition kind. The three from .C (refused, not supported yet, not expressible) are sufficient, and this stage adds no member to the last of them: every element it maps is reachable through a constructor the model exports.

§3 Models

// The catalog is a second index, parallel to refIndex's nodes and flows.
// It is separate because its contents are NOT flow elements: nothing in it
// can be a sequence flow's source, and a nodes-keyed lookup that also held
// messages would let a <sequenceFlow sourceRef="msg1"> resolve.
type catalog struct {
    messages    map[string]*bpmncommon.Message
    signals     map[string]*events.Signal
    errors      map[string]*bpmncommon.Error
    escalations map[string]*events.Escalation
    // kinds records which element declared each id, so a duplicate names the
    // element that already took it: BPMN ids are unique across a document,
    // and the four maps alone cannot see a collision between two of them.
    kinds map[string]string
}

A fifth map, itemRefs, was added later by SRD-094 FR-7 to check an event's data parameter against the item of the definition it pairs with. It is not part of this stage.

// defBuilder builds one event definition from its XML element. The table is
// keyed by tag exactly as nodeBuilders is, so adding the eleventh definition
// the standard may grow is one row.
//
// It landed taking the assembly and a recorded defSpec, and returning a
// builtDef, rather than the parser and a live xml.StartElement: §4.7 defers
// construction to pass 2, so by the time a builder runs the element is long
// consumed and what it has is the spec pass 1 recorded. §4.8 records the
// return type; the parameters moved for the same reason and in the same
// commit.
type defBuilder func(asm *assembly, owner string, s defSpec) (builtDef, error)

var defBuilders = map[string]defBuilder{
    tagMessageEventDef:     buildMessageDef,
    tagTimerEventDef:       buildTimerDef,
    tagSignalEventDef:      buildSignalDef,
    tagErrorEventDef:       buildErrorDef,
    tagEscalationEventDef:  buildEscalationDef,
    tagConditionalEventDef: buildConditionalDef,
    tagTerminateEventDef:   buildTerminateDef,
    tagCancelEventDef:      buildCancelDef,
    tagCompensateEventDef:  buildCompensationDef,
    tagLinkEventDef:        buildLinkDef,
}

// builtDef is one definition plus the option that attaches it to a start
// or end event. Both come from the same builder because only there is the
// definition's concrete type known — see §4.8. A nil trigger is how Link
// is excluded without the converter owning the rule.
type builtDef struct {
    def     flow.EventDefinition
    trigger options.Option
}
// nodeBody gains the definitions collected before construction — the same
// body-before-build order .A introduced and .C first used for <script>.
// Field order is the landed one (script, docs) with defs appended, arranged to
// satisfy govet/fieldalignment rather than to suppress it. (.A's //nolint:govet
// is on the exporter's xmlSequenceFlow, where field order IS the wire contract
// because encoding/xml emits children in it — a different problem from this
// one.)
type nodeBody struct {
    script string
    docs   []docSpec
    defs   []defSpec
}

Three fields is this stage's shape. It has since grown laneSets (.E), props, io, loop, dataAssocs, params and extra (.F–.H) — every one a child that decides what is built, arriving by the same body-before-build order this stage established.

defs holds specs, not built definitions — the same §4.7 deferral that reshaped defBuilder. What a definition refers to (a message, a signal, an error) may be declared later in the document, so pass 1 records the reference and pass 2 resolves it against the completed catalogs.

§4 Analysis & decisions

§4.1 A catalog object gets an empty item, and the loss is reported

The standard makes itemRef/structureRef optional; NewMessage and NewEscalation require one. Something has to give.

Option Verdict
Synthesize an empty item and report the dropped structure Chosen.
Refuse a message with no resolvable item Rejected. It refuses nearly every real diagram — a Camunda <message> carries id and name and nothing else — which would make the whole stage useless for its main audience.
Parse <itemDefinition structureRef="…"> and build a typed value Rejected as unreachable, not as unwanted. structureRef is a QName into an XSD or WSDL the converter does not have and cannot fetch, and NewItemDefinition needs a Go data.Value (data/item.go:74). There is no mechanical route from the one to the other.
Relax the constructors to accept a nil item Rejected. It changes a pkg/model contract to suit a converter, and every existing caller's guarantee with it.

values.NewRecord() with no fields succeeds (values/record.go:37-45), and NewItemDefinition handles a value that is nil or empty without complaint (item.go:86-89). So an empty record is a truthful placeholder: the message exists and carries no payload structure, which is exactly what the document said.

The report is the load-bearing half of this decision. A structure silently discarded is how a host discovers at run time that its message has no payload; a Dropped entry saying so at import is the difference between a converter that is honest about its limits and one that appears to have understood the file.

§4.2 A nameless catalog object takes its id

All four constructors require a non-empty name; the standard makes name optional. .A already solved this shape for nodes with fallbackName, and the same answer applies: use the id, which is required in practice and is what a modeler sees in the file anyway. The alternative — refusing a nameless <signal id="s1"/> — refuses a legal document over a cosmetic field.

§4.3 The converter does not own the boundary rules

The extract's matrix says Error on a boundary is always interrupting, Cancel attaches only to a transaction sub-process, and Link and None never appear there at all (event-handling.md:54-70). The converter could enforce all of that at parse time.

It will not. Those rules belong to the model, which already refuses what it cannot execute, and the runtime obeys the model's copy rather than the converter's. A second implementation adds a way for the two to disagree — and when they do, the converter's copy wins at import and the model's wins at run time, which is the worst possible split. The converter's job is to pass the definition and report the refusal with the file's element id attached, which is the one thing the model cannot do.

The cost is that a bad boundary combination is caught by a constructor error rather than by a purpose-written message. That is acceptable: the error still names the element, and it is right rather than merely well-worded.

§4.4 Two ways in, one table

A definition reaches an intermediate event positionally and a start or end event through an option. Rather than branch on the event position at every build site, triggerOptions maps definition → option once, and the positional path ignores it. Link's absence from that table is then not a special case in code — it is a missing row, which is the same mechanism that will exclude the next definition the standard confines to one position.

Corrected by §4.8: triggerOptions landed as a function, not a table — an option cannot be produced without the built definition it decorates. Link's exclusion is a builder that returns no trigger, not a missing row.

§4.5 The catalog is indexed separately from the nodes

refIndex holds nodes and flows; catalog objects are neither. Merging them would let a <sequenceFlow sourceRef="msg1"> resolve to a message and fail later with a type error far from the cause. A separate map makes a wrong-kind reference fail at the reference, which is .A's wrongKind path already built and tested.

§4.6 The timer is read by the model, not by the converter

BPMN carries a timer's value as a bare Expression with no format constraint (elements/event-definitions.md:56-58), and the value itself is an ISO 8601 string: an instant, a duration, or a recurrence.

NewTimerEventDefinition alone is the wrong entry point. It takes three already-typed expressions and checks each declared result type — "Time", "int", "Duration" (timer.go:75-92) — and the text expression language declares none of the last two. Reaching for it directly is what makes <timeDuration> and <timeCycle> look unreachable.

They are not. The model exports the reader:

Constructor Takes Produces
NewISO8601Timer(s, …) (timer_iso8601.go:43) one ISO 8601 literal the whole definition — R3/PT10H becomes timeCycle (3) and timeDuration (10h), because the engine carries a recurrence as (count, interval)
NewISO8601TimerExpr(form, e, …) (:117) a form plus an expression the same, adapting the expression's result to the type the constructor demands

and the grammar behind them is a public package — iso8601.ParseDateTime, ParseDuration, ParseRepeat.

Option Verdict
Hand the value to the model's ISO 8601 constructors Chosen. The converter decides only which of the three children the file wrote, and whether its body is a literal or an expression. The grammar, the disassembly and the result typing stay in one place.
Mint typed expressions in the converter Rejected. It is a second ISO 8601 implementation inside pkg/convert, and the model's would have to supersede it — the split §4.3 refuses on principle.
Refuse the duration and recurrence forms Rejected — and it is what an earlier draft of this section decided, from a survey of one constructor. The refusal was wrong: the capability it declared missing is exported, and the same wrong reason had already been written into .B's dialect table for failedJobRetryTimeCycle (corrected in M8). A boundary asserted without searching for the constructor that removes it is not a boundary.

Literal or expression is decided by the language, not by the text. A body declaring no language and showing no ${…} is a literal, which is what a timer normally carries; anything else is an expression whose value is computed per instance and reaches the model through the adapter. An unreadable literal is refused by the model's own error, which names all three grammars rather than guessing which one the author meant.

Two timer children are refused. BPMN makes timeDate exclusive with the other two, and this engine reads a recurrence from a single string — so a pair has no combined reading, and picking one would change when the timer fires.

§4.7 Every flow node is constructed after the document is read

Definitions.rootElements is an unordered 0..* collection and Process is itself a RootElement (elements/foundation.md:23), so a <message> may legally be declared after the <process> whose start event refers to it — and modeler output routinely does exactly that. An event definition is a positional constructor argument, so a node carrying one cannot be built until the whole document has been read.

The spine this stage inherited built each node as its element closed, the way serviceTask still resolves operationRef against a catalog that must already exist. That is a declare-before-use rule the standard does not impose.

Option Verdict
Record a nodeSpec in pass 1 and construct every node in pass 2 Chosen. One construction path for every element, and the id→node table is complete before the first constructor runs. Retaining the xml.StartElement is safe: encoding/xml allocates a fresh Attr slice per element and copies each value.
Construct in pass 2 only the nodes that carry a catalog reference Rejected. Two construction paths means two places an element can be wired differently, and which path a node takes would depend on its children — the least predictable possible rule.
Require catalog objects to precede the process, refusing a forward reference Rejected. It refuses schema-valid files for the convenience of the parser, which is §4.1's rejected option wearing a different hat.
Resolve the reference after construction, through .A's deferred references Rejected as impossible, not unwanted. NewIntermediateCatchEvent and NewBoundaryEvent take the definition positionally and reject nil; there is no post-construction setter to defer to.

The visible consequence is error order: a constructor's refusal now surfaces after the file has been read, so a parse failure later in the document is reported first. That is the correct order — the parse failure is the earlier fact — but it is a change, and §6 T-22 pins it rather than leaving it to be discovered.

§4.8 A definition carries its own trigger option

§4.4 first proposed a triggerOptions table mapping a definition onto the option a start or end event needs. It cannot be written that way: every With*Trigger takes a concrete type (*events.MessageEventDefinition, not flow.EventDefinition), so a table keyed by tag would have to assert the concrete type back out of the interface it was just widened into.

Each builder therefore returns the definition and its trigger together, in the one place where the concrete type is still known and no assertion is needed. triggerOptions remains as the function that renders them, and Link's exclusion still costs no branch: its builder produces no trigger, because the model has no WithLinkTrigger to produce.

§4.9 Import initializes the model's default data states

A message-typed event builds an ItemAwareElement over the message's payload, and that requires data.UnavailableDataState to exist (data/item.go:193-201). It is host setup — every runnable example calls data.CreateDefaultStates() before building a process — and nothing in the converter had ever needed it, because no element imported so far carries data.

Option Verdict
(*parser).parse calls data.CreateDefaultStates() before constructing nodes Chosen. The call is idempotent and guarded — it fills the three globals only when they are unset (data/state.go:86-91) — so a host that configured its own states keeps them.
Document the requirement and let the import fail Rejected. "Your message start event imports only if you first call an unrelated initializer" is not a usable contract for a converter, and the failure names data states rather than the file.
Call it from the package's init() Rejected. A blank import would then mutate model globals, which is a side effect no import statement should have.

§4.10 An intermediate event takes exactly one definition

BPMN lets a catch event list several triggers; NewIntermediateCatchEvent and NewIntermediateThrowEvent take one flow.EventDefinition positionally (NewIntermediateCatchEvent, NewIntermediateThrowEvent).

Option Verdict
Refuse an intermediate event carrying more than one definition Chosen. The refusal names the count and says what to do — split the triggers across separate events.
Import the first and report the rest Rejected. The event would then wait for less than the file asked for, and the difference between "waits for A" and "waits for A or B" is not a detail a Dropped entry makes safe.
Attach the extras through a multi-trigger option Rejected as absent: the model has no such option here. newCatchEvent takes a definition slice, but the exported constructor passes a single-element one, and widening it is a pkg/model change (§4.1's rejected shape).

Zero definitions is refused for the constructor's own reason, and nothing legal is lost: the standard has no untyped intermediate event either.

§4.11 A compensation boundary waits for associations

NewBoundaryEvent refuses a compensation trigger outright and points at NewCompensationBoundaryEvent, which takes a handler activity (NewCompensationBoundaryEvent in boundary.go). BPMN names that handler through an <association> from the boundary event to the activity — an element .A left deliberately refused as "mapped work for the composites stage", and one this stage does not import.

The converter therefore refuses a compensation boundary with a message naming what the IMPORT is missing. Letting the model's own error through would tell a modeler to call a Go constructor, which is not an action available to someone holding a .bpmn file. SRD-089.E lands <association> and removes this refusal; the same file then imports unchanged.

§4.12 A timer waiter needs a data source it is not always given

T-16 — the end-to-end run — found what construction tests structurally cannot: an imported <timeDate> timer built, registered, and then failed to start its instance. timeWaiter.parseEDef read its data source as ds, _ := ep.(data.Source), and a start event's event processor is not one, so ds was nil — which the expression engine refuses before it ever reads the expression. (Past tense: the fix below is what the line reads now.)

The defect predates this stage and was unreachable from it: every existing timer test uses a functor expression, which evaluates itself and ignores the source. Only a text expression routes through the engine registry that validates the source, and a text expression is exactly what an importer can mint.

Fixed at the call site rather than in the engine: the waiter passes an empty data.Source when its processor is not one. A literal date reads no variable and evaluates fine against it; an expression that does read one now fails inside the lookup naming the variable, instead of failing at a nil guard that can name nothing. Relaxing the engine's nil check was rejected — validating a public API's parameters is the rule the guard exists to keep, and the caller holding a nil is the actual defect.

§4.13 Every node reports its dialect attributes

M8 landed the retry policy and could not be tested on a <task>: the attribute never reached the mapper. camundaOptions — which both maps the recognized attributes AND calls reportUnmappedAttrs for the rest — is invoked by the user, service and rule task builders and by nobody else.

So a dialect attribute on a plain <task>, a <manualTask>, a <scriptTask>, a gateway or an event is silently dropped: not mapped, and not reported either. That is precisely the failure the report contract exists to prevent, surviving in the elements nobody had thought to check.

The fix is to make the call unconditional rather than per-builder, so a node kind added later cannot forget it. A builder that maps an attribute claims it; everything unclaimed is reported once, and a construct stays either mapped or reported and never both.

§5 API deltas

None. Every element here uses an existing constructor, and pkg/model is untouched — §4.1 records why relaxing a model contract was rejected.

§6 Test scenarios

# Scenario Asserts
T-1 <message>, <signal>, <error errorCode>, <escalation escalationCode> all four import; codes carried (FR-1)
T-2 A catalog object with itemRef/structureRef imports; one Dropped entry per occurrence naming the structure (FR-8, §4.1)
T-3 <signal id="s1"/> with no name imports under the id (§4.2)
T-4 A start event per trigger: message, <timeDate> timer, signal, error, escalation each imports through its option (FR-2, FR-3). A conditional start was listed here too and is not covered: the model refuses one on a top-level start event, which T-11 asserts and NFR-1 requires the converter to delegate rather than second-guess. A conditional start belongs to an event sub-process (ADR-023 v.5 §2.10), so the row and T-11 contradicted each other and T-11 is the correct one.
T-5 An end event with terminate, error, escalation, compensation each imports (FR-2, FR-3). A cancel end was listed here too and is refused outside a transaction — by the model, per §8. It became reachable only when .E landed the transaction variant, and is covered there by TestCancelBecomesReachable.
T-6 <intermediateCatchEvent> with <timeDate> timer, message, signal, conditional, link each imports positionally (FR-4)
T-7 <intermediateThrowEvent> with link, signal, escalation, compensation each imports (FR-4)
T-8 An intermediate event with no definition refused, naming the requirement (FR-4)
T-9 <boundaryEvent attachedToRef> with and without cancelActivity imports; default true (FR-5)
T-24 A <boundaryEvent> declared before the activity it guards imports; a process's flow elements are no more ordered than a document's root ones (§4.7)
T-25 A compensation <boundaryEvent> refused, naming the <association> the import still lacks (§4.11). Superseded by SRD-089.E, which landed associations: a compensation boundary WITH one now builds its handler wiring, and the refusal that survives is for a file that draws none — so it names the FILE's gap, not the import's (TestCompensationBoundaryWithoutAnAssociation).
T-10 A boundary event attached to a gateway, and to a missing id each an error naming both ends (FR-5, FR-7)
T-11 A conditional start event at top level, and a cancel end event outside a transaction the model's refusal surfaces, with the element id attached (NFR-1, §4.3)
T-12 <sendTask messageRef> and <receiveTask messageRef> import; the message resolves (FR-6)
T-26 <receiveTask instantiate> the flag reaches the model; default false (FR-6)
T-27 A messaging task's implementation and operationRef reported — the model holds neither (FR-8)
T-13 Each of the six reference attributes pointing at a missing id an error naming the attribute and the target (FR-7)
T-14 <messageEventDefinition messageRef> pointing at a <signal> wrongKind, not a nil dereference (§4.5)
T-15 A <startEvent> with a <linkEventDefinition> refused — no trigger option exists (§4.4, FR-3)
T-16 A process with a <timeDate> timer start and a boundary error handler, run on a thresher registers and completes — the events are wired, not merely constructed (§4.12)
T-17 The .A/.B/.C fixture corpus unchanged verdicts
T-18 <timerEventDefinition> with <timeDate>, <timeDuration>, <timeCycle> each imports; a recurrence fills cycle AND duration (FR-2, §4.6)
T-19 An unreadable timer literal, and two timer children at once each refused at import, by the model's grammar error and by the exclusivity rule (§4.6)
T-20 <timeDuration>${deadline}</timeDuration> imports through NewISO8601TimerExpr — a per-instance deadline is expressible, not only a literal (§4.6)
T-21 A <conditionExpression> on a flow and a <conditionalEventDefinition> body, same language attribute both mint through the extracted body-level helper; the flow's verdicts are unchanged from .B (§1)
T-22 A file whose node fails to construct and whose LATER element fails to parse the parse failure is reported — nodes are built only after the document is read (§4.7)
T-23 A <message> declared after the <process> that refers to it imports; the reference resolves (§4.7)

§7 Milestones

M Content
M1 The four catalogs + the item decision (FR-1, FR-8, §4.1, §4.2)
M2 The spine this stage needs: deferred node construction (§4.7) and .B's body-level expression helper extracted out of newCondition (§1)
M3 defBuilders: the ten definitions, timer narrowed to <timeDate>, attached to start and end events through triggerOptions (FR-2, FR-3, FR-7, §4.4, §4.6)
M4 Intermediate catch and throw (FR-4)
M5 Boundary events (FR-5, §4.3)
M6 Send and receive tasks (FR-6)
M7 Timers through the model's ISO 8601 constructors, correcting §4.6 (FR-2)
M8 The dialect row that carried the same wrong reason (failedJobRetryTimeCycle)
M9 Every node reports its dialect attributes — found while landing M8 (§4.13)

M2 and M3 are not the split §7 first carried. The definitions were to land before the triggers, but neither is observable alone — a definition with no event to attach it to changes nothing a test can see — and the ordering finding in §4.7 turned out to be the real prerequisite for both.

§8 Cross-doc

  • Implements ADR-024 v.4 §2.9 and §2.15. The ADR is Draft, accepted when the element set completes at the end of this branch.
  • SRD-089.A, SRD-089.B and SRD-089.C are landed stages of this same branch and are not edited here.
  • The composites that host event sub-processes and transaction-scoped cancel events land in SRD-089.E; this stage imports the events themselves, and a cancel event — which the model accepts only inside or on a transaction — is therefore refused until that stage exists. Reachable since .E: TestCancelBecomesReachable imports both positions, and T-5 records it.

§9 Definition of Done

  1. FR-1…FR-8 wired, each with its §6 test.
  2. make ci green, judged from .ci/last-run.json. At acceptance the gate is unreliable for reasons outside this stage — internal/instance flakes at about one package run in three (#356) and govulncheck intermittently cannot reach its database — so the evidence taken is go test ./pkg/convert/bpmn/, make lint and make link-check, all green, plus the full-gate PASS this branch recorded before the flake surfaced.
  3. T-16 demonstrates the added events running, not merely importing.
  4. /check-srd PASS; /pr-review runs once at the branch's end.

§10 Implementation summary

Filled retroactively at the .F landing, from the branch history.

Nine milestones, in the order §7 records them — including its own note on why M2 and M3 are not the split first planned.

M Commit What landed
M1 a459d785 the four catalogs an event definition refers to (FR-1, FR-8)
M2 2836f964 deferred node construction — nodes build after the document, not during it (§4.7)
M3 b1937c04 the ten definitions in defBuilders, attached through triggerOptions (FR-2, FR-3, FR-7); a6a1f48d closes their failure paths
M4 367ae153 intermediate catch and throw (FR-4)
M5 f9ad7b15 boundary events and the activities they guard (FR-5, §4.3)
M6 2c9198fe send and receive tasks (FR-6)
M7 6a6c8638 all three timer forms through the model's ISO 8601 constructors — the earlier refusal's stated reason was wrong (FR-2, §4.6 corrected)
M8 0db365b6 failedJobRetryTimeCycle maps as a retry policy, closing the dialect row that carried the same wrong reason
M9 457833da every node reports its dialect attributes through one funnel (§4.13)

M2's deferral kept paying: .F moved the PROCESS's construction to the same place for the same document-order reason (SRD-089.F §4.6), whose amendment names nodeSpec's doc comment — the shape §4.7's ordering finding left behind — as the precedent it followed.

Open questions

None. The two genuine tensions are decided rather than deferred: §4.1 answers a standard that makes a payload structure optional against constructors that do not, and §4.6 answers a timer whose two recurrence forms the expression language cannot type. Both carry their alternatives and a reporting obligation; neither is left for the implementer to settle.