Skip to content

SRD-089.C — BPMN import: the flow nodes a graph is built from

Field Value
Status Accepted
Date 2026-08-11
Owner Ruslan Gabitov
Implements ADR-024 v.4 §2.9 (the element set), §2.11 (scripts, wired here), §2.12 (business rules, wired here), §2.13 (the global-task refusal)
Upstream ADR-005 v.5 (gateway semantics), ADR-031 v.1, ADR-027 v.1
Related SRD-089.A (the dispatch tables this extends), SRD-089.B (the script and decision-reference policies this wires)
Tracking #284 — Part of

This is the stage where the fence actually moves. .A rebuilt the spine and .B taught it the languages a definition carries; this one adds elements. It takes the flow nodes whose constructors need nothing the converter cannot yet supply, and it refuses — precisely and by name — the two that need something it cannot.


§1 Background (verified)

The importer maps nine elements. What this stage can add is decided not by appetite but by what each model constructor requires, and three of those requirements bite:

Script and business-rule tasks need only what .B already built. activities.NewScriptTask(name, format, body string, …) (pkg/model/activities/script_task.go:39) needs a format and a body — the format policy landed in .B, and the body is <bpmn:script>, a child element (elements/activities.md:212). activities.NewBusinessRuleTask(name, decisionRef string, …) (brule_task.go:35) needs an opaque reference, which .B's dialect table already reads.

Two gateways need nothing at all. gateways.NewInclusiveGateway(opts …) (inclusive.go:41) and NewEventBasedGateway(opts …) (event_based.go:122) take only options, like the two already imported.

The complex gateway needs something the XML does not carry. NewComplexGateway fails unless an activation rule is set: "complex gateway requires an activation rule (WithActivationThreshold or WithActivation)" (complex.go:113-119), and those take an integer threshold or Triples — per-gate token counts. BPMN carries activation as activationCondition, an Expression (elements/gateways.md:158), which the semantics describe as "an explicit Boolean expression over per-incoming-flow token counts" (semantics/gateways.md:119).

Send and receive tasks need a message. NewSendTask(name, msg *bpmncommon.Message, …) (send_task.go:29) and NewReceiveTask(name, msg *bpmncommon.Message, …) (receive_task.go:58) both take a message positionally. A message comes from a definitions-level <bpmn:message> catalog, which the importer refuses today and which belongs with the message events that share its machinery.

Intermediate events need a definition. NewIntermediateCatchEvent(name, def flow.EventDefinition, …) rejects a nil definition (intermediate_catch.go:38-45), as does the throw. There is no such thing as a "plain" intermediate event to import ahead of the definitions.

§2 Requirements

Functional

  • FR-1 — <scriptTask> imports when its scriptFormat names Lua and it carries a <bpmn:script> body; .B's policy supplies every refusal.
  • FR-2 — <businessRuleTask> imports carrying its decision reference, read from the recognized dialect or from implementation. A rule task with no resolvable reference is refused: a rule task with no decision fails at its first execution with far less context than the importer has now.
  • FR-3 — <inclusiveGateway> and <eventBasedGateway> import, with the gatewayDirection and (inclusive only) default the existing gateway path already handles.
  • FR-4 — <complexGateway> is refused, naming the mismatch (§4.1).
  • FR-5 — The GlobalTask family is refused with a distinct not supported yet error, separate from the unsupported-element error, per ADR-024 §2.13 — and so is a <callActivity> whose calledElement names one.

Landed as one half, and now superseded whole. The family's own five tags refuse as specified. The calledElement-names-one clause was never coded: buildCallActivity checks for a QName prefix and nothing else, so the refusal held only transitively — a file declaring the <globalTask> tripped on the declaration first, while a calledElement naming an undeclared global task imported as an ordinary key. The gap is moot rather than open: SRD-096 removes the family's refusal entirely — a global task becomes a callable process, so a calledElement naming one is a key the registry serves. - FR-6 — <sendTask>, <receiveTask> and the intermediate events keep their current refusal, unchanged, until .D supplies the message catalog and the event definitions their constructors require.

Non-functional

  • NFR-1 — Every added element round-trips through export at least as far as the existing subset does, or the export gap is stated. (Export still covers the MVP set: ADR-024 §5 accepts import ⊃ export until slice 3, and this stage widens that gap knowingly.)
  • NFR-2 — make ci green, cover-check at COVER_MIN.
  • NFR-3 — Refusals stay distinguishable. Three kinds now travel: not in the subset, not supported yet (a deferral), and not expressible (§4.1). A host that branches on them must be able to tell them apart.

§3 Models

// The new rows are rows — nodeBuilders gains four entries, and the process
// table derives from it as .A established, so nothing else changes.
var nodeBuilders = map[string]nodeBuilder{
    // … the eight already there …
    tagScriptTask:        buildScriptTask,
    tagBusinessRuleTask:  buildBusinessRuleTask,
    tagInclusiveGateway:  buildGateway,
    tagEventBasedGtw:     buildGateway, // spelled short in the landed table
}

// nodeBody gains the script body — the first child that decides WHAT is
// built rather than decorating it, which is the shape .A's body-before-build
// reorder was made for.
type nodeBody struct {
    docs   []docSpec
    script string // <bpmn:script>, for a scriptTask
}
// notYet is the third disposition ADR-024 §2.13 asks for. It was left out
// of .A deliberately — a disposition with no rows is untested code that
// looks like coverage — and arrives here with its first users.
const notYet dispositionKind = iota + 2

§4 Analysis & decisions

§4.1 The complex gateway is refused, and the reason is structural

Option Verdict
Refuse <complexGateway>, naming the mismatch Chosen.
Derive a threshold from the activationCondition Rejected. The condition is an arbitrary Boolean expression; recovering "how many incoming flows" from it is not mechanical, and a wrong threshold changes when the gateway fires — silently, at run time.
Import it with a default rule (say, all incoming) Rejected, and worse: it produces a gateway that works, just not the one the file describes.

This is the uncomfortable case in the whole slice: an element the engine executes that its XML form cannot reach. The model encodes activation as per-gate counts, which is a structured view of the same domain the standard describes with an expression — and structure cannot be recovered from an arbitrary expression by a converter. The refusal says exactly that and points at the programmatic constructor, so nothing about it reads like a gap someone forgot to fill.

§4.2 Refusals are now three kinds, and stay so

.A's dispositions were skip and refuse. This stage needs a third: the global-task refusal means "waiting on a subsystem", the complex-gateway refusal means "the two forms do not correspond", and the ordinary one means "not in the subset yet". They lead a reader to three different actions — wait, rewrite, or check the roadmap — so flattening them into one message would waste the only information the error carries.

§4.3 What a script task's body is

<bpmn:script> is a child element, so it arrives through the nodeBody collected before construction — the mechanism .A introduced for documentation and predicted would be needed "from the next stage on, [when] the children decide WHAT to build". This is that stage: a script task with no body cannot be constructed at all, since NewScriptTask requires a non-empty one.

§5 API deltas

None. Every element added here uses an existing constructor; the converter's own surface is unchanged, and pkg/model is untouched.

§6 Test scenarios

# Scenario Asserts
T-1 A Lua <scriptTask> with a <bpmn:script> body imports; format and body reach the model (FR-1)
T-2 scriptFormat of gofunc, javascript, and absent each refused with .B's reason (FR-1)
T-3 A <scriptTask> with a format but no <script> refused, naming the missing body
T-4 <businessRuleTask camunda:decisionRef="d1"> imports; DecisionRef() == "d1" (FR-2)
T-5 <businessRuleTask> with no reference anywhere refused (FR-2)
T-6 <inclusiveGateway> with default and gatewayDirection imports; default resolves through .A's deferred references (FR-3)
T-7 <eventBasedGateway> imports (FR-3)
T-8 <complexGateway>, with and without an activationCondition refused both times, naming the mismatch and the constructor (FR-4)
T-9 Each global*Task, and a callActivity naming one refused as not supported yet, distinguishable from the ordinary refusal (FR-5, NFR-3)
T-10 <sendTask>, <receiveTask>, <intermediateCatchEvent> still refused, unchanged (FR-6)
T-11 A process using every newly added element, run on a thresher registers and completes — the elements are wired, not merely constructed. Delivered late, by TestFlowNodesRunOnAThresher (script task, business rule task, inclusive gateway split/join) and TestEventBasedGatewayRunsOnAThresher (the gateway completes by waiting on its timer branch) — the stage landed without it, and a /check-srd audit before the status flip is what found the DoD item open.
T-12 The .A/.B fixture corpus unchanged verdicts

§7 Milestones

M Content
M1 The two gateways (FR-3)
M2 <scriptTask> + the script body in nodeBody (FR-1, §4.3)
M3 <businessRuleTask> + the decision reference (FR-2)
M4 The refusals: complex gateway, the global-task family, notYet (FR-4, FR-5, §4.2)

§8 Cross-doc

  • Implements ADR-024 v.4 §2.9, §2.11–§2.13. The ADR is Draft, accepted when the element set completes at .E.
  • SRD-089.A and SRD-089.B are landed stages of this same branch and are not edited here.

§9 Definition of Done

  1. FR-1…FR-6 wired, each with its §6 test.
  2. make ci green, judged from .ci/last-run.json.
  3. T-11 demonstrates the added elements running, not merely importing.
  4. /check-srd PASS; /pr-review runs once at the branch's end, after .E.

§10 Implementation summary

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

Four milestones, in the order §7 planned them.

M Commit What landed
M1 14682808 the inclusive and event-based gateways (FR-3)
M2 be53b53c <scriptTask>, and the body reader that lets a child decide what is built (FR-1, §4.3)
M3 8a6fc6de <businessRuleTask> and its decision reference (FR-2)
M4 23c44461 the refusals: complex gateway, the global-task family, notYet (FR-4, FR-5, §4.2)

M2's body reader became the load-bearing piece of the stage: the same children-before-constructor order later carries .D's event definitions and .F's properties, exactly because a child that decides WHAT to build cannot follow the construction it decides.

Open questions

None. §4.1 records the one uncomfortable outcome — an executable element the XML form cannot reach — as a decision with its alternatives, not as an open question.