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 ownlanguage, else the document'sexpressionLanguage, else is refused. So the XPath schema default is not honoured — neither when a document omitsexpressionLanguageand 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:litepasses through as adata.TextExpression, so an imported condition is runnable where today every one of them faults. - FR-3 — JUEL is translated into
gobpm:litesource, 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
scriptFormatthe Lua battery claims imports; every other format, and an absent one, is refused by name.gofuncis 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 —
Importis unchanged for existing callers. The reporting capability is additive;Importkeeps its signature and meaning. - NFR-3 —
make cigreen,cover-checkatCOVER_MINon 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 |
reported — hinteraction.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/gofunclanded 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¶
- FR-1…FR-8 wired, each with its §6 test.
make cigreen —cover-checkatCOVER_MINon every touched file, judged from.ci/last-run.json, never a wrapper's exit code.- An imported conditional flow runs on a thresher with the lite engine wired — the defect in §1 demonstrably closed, not merely re-specified.
/check-srdPASS →/pr-reviewat 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.