Skip to content

SRD-089.B — BPMN import: the languages a definition carries, and the Camunda dialect

Field Value
Status Accepted
Date 2026-08-11
Owner Ruslan Gabitov
Implements ADR-024 v.4 §2.10 (expressions), §2.11 (scripts), §2.12 (business rules), §2.14 (the recognized dialect), and the reporting half of §2.15
Upstream ADR-032 v.1 (expressions route by language claim), ADR-031 v.1 (scripts route by scriptFormat), ADR-027 v.1 (the opaque decision reference), ADR-020 v.3 (the assignment vocabulary the dialect maps onto)
Related SRD-089.A (the spine this extends — dispatch tables, deferred references), BPMN converter coverage audit 2026-08-10 §5 (the measured Camunda gap)
Tracking #284 (converter element coverage) — Part of

A definition carries behaviour in three languages the converter has no policy for, and vendor configuration it discards without a word. This stage decides all four: which expression languages import (and how JUEL becomes one that does), which script formats import, how a decision reference arrives, and what the Camunda 7 dialect maps to — with a channel for reporting what was deliberately not mapped, because §2.14's rule is that a recognized construct is never dropped in silence.

It adds no BPMN element. The element set moves in .C and .D; this stage makes the elements already imported, and every element after them, carry their behaviour.


§1 Background (verified)

Expressions route by language claim, and the converter claims nothing. data.NewTextExpression(language, body string, …) (pkg/model/data/text_expression.go:77) is the text kind; lite.Language = "gobpm:lite" (pkg/model/expression/lite/lite.go:26) is the battery that interprets it. The converter builds neither: a <conditionExpression> becomes its own inert formalExpression whose Evaluate always fails (pkg/convert/bpmn/expression.go), so every imported condition is unrunnable — the file imports, the process registers, and the first gateway decision faults.

gobpm:goexpr cannot be the target. It is the functor kind, and text_expression.go:15-16 says so: "The functor kind deliberately does not implement it — its logic is the closure, not a text." No XML string can become a Go closure, so a translation target must be the text kind.

Two script batteries ship, and only one interprets the file's own text. lua.LuaType = "##Lua" claims text/x-lua, application/x-lua, lua (adapters/lua/engine.go:30,34). gofunc.GoFuncType = "##GoFunc" claims application/x-gobpm-gofunc, gofunc (pkg/script/gofunc/gofunc.go:43,48) and its "script text names a Go function the host registered" (:1-2). activities.NewScriptTask(name, format, body string, …) (pkg/model/activities/script_task.go:39) requires a non-empty format.

A business rule task already holds an opaque reference. activities.NewBusinessRuleTask(name, decisionRef string, …) (pkg/model/activities/brule_task.go:35) — and BPMN gives BusinessRuleTask only implementation as an own property (elements/activities.md:253), so the reference is vendor vocabulary by construction.

The Camunda gap is measured, not estimated. The audit drove a stock Camunda 7 file through import and re-export: it imported "successfully" and the re-export contained the string camunda zero times. Five dropped values have model homes today — WithAssignee/WithAssigneeExpr (:108,121), WithCandidateUsers(Expr) (:134,147), WithCandidateGroups(Expr) (:160,173) in user_task_options.go; WithWorker(topic) (:93) and WithRetryPolicy (:124) in service_task_options.go.

§2 Requirements

Functional

  • FR-1 — Expression language is resolved, not assumed. The body decides first: an expression carrying ${…}/#{…} is JUEL whatever the expression or the document declares. Any other body takes its own language, else the document's expressionLanguage, else is refused. So the XPath schema default is not honoured — neither when a document omits expressionLanguage and leaves the default implicit, nor when its tool writes the default out and then emits JUEL under it (ADR-024 §2.10).
  • FR-2 — gobpm:lite passes through as a data.TextExpression, so an imported condition is runnable where today every one of them faults.
  • FR-3 — JUEL is translated into gobpm:lite source, and an untranslatable construct is refused by name — never partially rewritten.
  • FR-4 — FEEL, XPath and any other language are refused, classified, naming the language.
  • FR-5 — Scripts: Lua only. A scriptFormat the Lua battery claims imports; every other format, and an absent one, is refused by name. gofunc is refused with the registry reason (ADR-024 §2.11).
  • FR-6 — A business-rule decision reference is carried opaquely, from the dialect or implementation; no DMN is parsed. A rule task with no resolvable reference is refused.
  • FR-7 — The Camunda 7 dialect maps what the model already holds — the assignment triad, the external-task topic, the retry cycle, the decision reference — and maps nothing that would need a new model type.
  • FR-8 — A recognized construct is never dropped in silence. Anything in a recognized namespace that is not mapped is reported through the §2.15 capability. An unrecognized namespace stays silent.

Non-functional

  • NFR-1 — No new BPMN element. The set of mapped elements is exactly .A's; a file refused before this stage is refused after it, unless the refusal was a language or dialect policy this stage decides.
  • NFR-2 — Import is unchanged for existing callers. The reporting capability is additive; Import keeps its signature and meaning.
  • NFR-3 — make ci green, cover-check at COVER_MIN on every touched file.
  • NFR-4 — The translator never guesses. No JUEL construct is dropped, approximated, or rewritten into something with different semantics.

§3 Models

// package convert — the reporting capability (ADR-024 v.4 §2.15).

// Dropped names one recognized construct the converter did not map.
type Dropped struct {
    // Element is the owning element's id, where the source has one.
    Element string
    // Construct is what was dropped, in the source's own vocabulary
    // (e.g. "camunda:asyncBefore", "camunda:executionListener").
    Construct string
    // Reason says why, in terms a modeller can act on.
    Reason string
}

// Result is everything one source document yielded.
type Result struct {
    Processes []*process.Process
    Dropped   []Dropped
}

// DocumentImporter is the optional capability of an Importer that can
// report what it knowingly did not map, or carry more than one definition
// per document. The façade probes for it and falls back to Import.
type DocumentImporter interface {
    ImportDocument(ctx context.Context, r io.Reader) (*Result, error)
}
// package bpmn — the language policy, as data.

// exprLang is a resolved expression language.
type exprLang uint8

const (
    langRefused exprLang = iota // FEEL, XPath, anything else — and the zero
    langLite                    // gobpm:lite — passes through
    langJUEL                    // translated into lite
)

// languages maps a declared language URI to the policy for it. A URI absent
// from the table is refused — which is why refusal, not "unknown", is the
// zero value: a table miss and an unsupported language are the same answer,
// and a separate unknown state would be a fourth case nothing acts on.
//
// The table decides only the bodies the DELIMITERS do not: a ${…} body is
// JUEL before this map is consulted (FR-1).
var languages = map[string]exprLang{ /* … */ }

§4 Analysis & decisions

§4.1 The translator is a rewriter, not an interpreter

JUEL and gobpm:lite share comparison, member access, index access and literals; they differ in delimiters, in boolean spelling (&&/||/! versus and/or/not, lite's lexer at lexer.go:39-44), and in the variable-access idiom. So the translation is a source-to-source rewrite over a small grammar, not a second evaluator.

Option Verdict
Rewrite JUEL source into lite source Chosen. One expression semantics in the codebase, owned by the battery that already has it.
A JUEL engine registered under its own language claim Rejected: a second permanent implementation of coercion, null handling and member resolution, to gain what the rewrite already gives.
Carry JUEL through as inert text Rejected: that is today's behaviour for every condition, and it is the defect — the process imports and faults at its first decision.

Refusal is by name and total. A construct with no counterpart — a method call on a host bean, anything reaching outside the process's own data — is refused naming the construct. A translator that silently dropped what it could not express would produce a condition that parses, evaluates, and routes the token the wrong way; the failure would surface as a mis-executed process, far from the import.

§4.2 Reporting lands whole, here

ADR-024 §2.15 defines DocumentImporter/Result, and §2.14's rule 2 requires somewhere to put a report. Defining Dropped without its carrier would be a type with no caller — the same dead-code shape .A refused twice (notYet, nodeRef). So the capability lands complete in this stage, with Processes carrying exactly one element; the multi-process and collaboration half is .E's, which extends the same type rather than introducing it.

§4.3 The dialect maps only what exists, and reports the rest

Camunda 7 construct Disposition
camunda:assignee (+ …Expr form) activities.WithAssignee / WithAssigneeExpr
camunda:candidateUsers / candidateGroups WithCandidateUsers / WithCandidateGroups (+ …Expr)
camunda:type="external" + camunda:topic activities.WithWorker(topic)
camunda:failedJobRetryTimeCycle activities.WithIncidentRetryPolicy(tasks.FixedDelay(…)) — §4.4
camunda:decisionRef → the BusinessRuleTask decision reference (FR-6)
camunda:class · delegateExpression · expression · camunda:script reported — JVM-specific; no Go host can honour them
camunda:executionListener · taskListener reported — no model home
camunda:asyncBefore · asyncAfter · exclusive · jobPriority reported — they describe another engine's job executor
camunda:formKey · formData reportedhinteraction.Renderer is Go code, not a form URL
camunda:inputOutput reported here; the data stage (.E) decides whether it maps
camunda:properties · versionTag · historyTimeToLive reported — no model home

§4.4 The retry cycle maps — settled

camunda:failedJobRetryTimeCycle is an ISO-8601 repeating interval (R3/PT5M), and this section carried the open point of whether it maps onto a tasks.RetryPolicy faithfully or is reported instead, to be decided against the policy's real shape in the milestone that implements it. It maps. R3/PT5M is three attempts five minutes apart, iso8601.ParseRepeat reads exactly that (count, interval) pair, and tasks.FixedDelay takes exactly that pair — so the attribute reaches the model through activities.WithIncidentRetryPolicy with nothing approximated, and stops being reported: a construct is either mapped or reported, never both.

The half that stands is the reason the point was left open at all — a partial mapping that silently changes retry timing is worse than a report. So a value ParseRepeat cannot read is reported rather than guessed at, because a policy invented from an unreadable string would change how often a failing task is retried with nothing downstream saying so.

This section first refused the attribute outright, on the ground that reading a recurrence needed a parser that existed only unexported in the model layer. pkg/iso8601 is public and always was. The wrong reason did not stay here: it was cited a stage later as corroboration for refusing two of the three timer forms (SRD-089.D §4.6), so one unverified claim produced two refusals in two stages before either was checked.

§5 API deltas

Surface Delta
pkg/convert new: Dropped, Result, DocumentImporter, and a façade ImportDocument that falls back to Import. Additive — Importer, Exporter, Import, Export unchanged.
pkg/convert/bpmn internal: the language table, the JUEL→lite translator, the dialect table.

§6 Test scenarios

# Scenario Asserts
T-1 A gobpm:lite condition on a sequence flow imports as data.TextExpression; evaluates under a wired registry (FR-2)
T-2 ${total > 100 && tier == "gold"} becomes total > 100 and tier == "gold" and evaluates (FR-3)
T-3 ${!approved \|\| blocked}, ${order.customer.tier == 'vip'}, ${items[0] == "sku-1"}, ${execution.getVariable("total") > 0} each translated per ADR-024 §2.10's table
T-4 ${myBean.check(order)} refused, naming the construct; no partial rewrite (FR-3, NFR-4)
T-5 A FEEL and an XPath expression refused, classified, naming the language (FR-4)
T-6 scriptFormat of lua, text/x-lua, application/x-lua imports (FR-5)
T-7 scriptFormat of gofunc, of javascript, and absent each refused by name; the gofunc refusal states the registry reason (FR-5)
T-8 camunda:decisionRef on a business rule task carried as the decision reference (FR-6)
T-9 The audit's stock Camunda 7 file assignee, candidate groups and topic arrive on the model; every unmapped camunda: construct appears in Result.Dropped with a reason (FR-7, FR-8)
T-10 A zeebe:-namespaced file imports; nothing is reported — an unrecognized namespace stays silent (FR-8)
T-11 ImportDocument on an importer without the capability falls back to Import, one process, no report (NFR-2)
T-12 The .A fixture corpus unchanged verdicts (NFR-1)

§7 Milestones

M Content
M1 pkg/convert: Dropped, Result, DocumentImporter, façade + fallback
M2 Expression language resolution + gobpm:lite passthrough (FR-1, FR-2)
M3 The JUEL→lite translator and its refusals (FR-3, FR-4, NFR-4)
M4 Scripts (FR-5)
M5 The dialect table + decision reference + reporting (FR-6, FR-7, FR-8)

§8 Cross-doc

  • Implements ADR-024 v.4 §2.10–§2.12, §2.14, §2.15. The ADR is Draft, accepted when the element set completes; §2.11's stated reason was corrected in this branch after pkg/script/gofunc landed on master.
  • Sideways: ADR-032 v.1, ADR-031 v.1, ADR-027 v.1, ADR-020 v.3 — the seams whose vocabulary this maps onto.
  • SRD-089.A is not edited by this stage.

§9 Definition of Done

  1. FR-1…FR-8 wired, each with its §6 test.
  2. make ci green — cover-check at COVER_MIN on every touched file, judged from .ci/last-run.json, never a wrapper's exit code.
  3. An imported conditional flow runs on a thresher with the lite engine wired — the defect in §1 demonstrably closed, not merely re-specified.
  4. /check-srd PASS → /pr-review at the branch's end (after .E).

§10 Implementation summary

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

Five milestones, in the order §7 planned them.

M Commit What landed
M1 786d1ad7 pkg/convert: Dropped, Result, DocumentImporter, façade + fallback
M2 07fb252f expression-language resolution and the gobpm:lite passthrough (FR-1, FR-2)
M3 9b0b2739 the JUEL→lite translator and its refusals (FR-3, FR-4); a1276aba records the equivalence and its limits
M4 1ed15bc1 the script-format policy, and a guard for the list it copies (FR-5)
M5 68b005ab the dialect table, the decision reference, the reporting funnel (FR-6, FR-7, FR-8) — b401c4fd then routed the Camunda topic through the same vocabulary

Amended by .D, in this branch. Two of this stage's dispositions turned out to be wrong for the same reason — a refusal whose stated cause was not the real one: all three timer forms import (6a6c8638), and failedJobRetryTimeCycle is a retry-policy mapping rather than a refusal (0db365b6, §4.4 settled by 06ae7c00). Both are recorded in SRD-089.D §7 as its M7 and M8.

Open questions

None. §4.4 names one point decided during implementation against the code's real shape, with both outcomes acceptable and the choice recorded in §10.