ADR-031 — The Script Task and the pluggable Script Engine seam¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-25 |
| Owner | Ruslan Gabitov |
| Refines | ADR-027 v.1 §2.5 (the standing decision this ADR fulfils: the Script Engine is a pluggable seam of the same interface-plus-default shape as the rule engine — script interpreters are swappable engine services, never baked in; everything else was deferred to this conception), ADR-002 v.2 §4.1/§4.2 (the five-point extension pattern), ADR-003 §4.4 (interpreters live in adapter modules — the core stays stdlib+uuid), ADR-029 v.1 (the adapter-tier engine precedent this mirrors), SAD-001 v.1 §11 |
The Script Task is the last conformance-scope task type with no execution.
This ADR decides how it executes: a pluggable Script Engine seam —
format-routed by the standard's own scriptFormat MIME hint — with a
null default in core (the engine stays stdlib-light; interpreters are
adapters) and Lua as the batteries adapter (adapters/lua,
pure-Go gopher-lua).
1. Context & problem¶
The model stub is bare — ScriptTask{ScriptFormat, Script string; task}
with no constructor and no execution. The standard's clause is minimal
(the extract's §ScriptTask semantics, sourced from BPMN 2.0 §13.3): upon
activation the associated script
is invoked; on the script's completion the Script Task completes. The
element carries exactly two own attributes: script (the source body)
and scriptFormat (a MIME-type hint) — the language is deliberately
unspecified; the extract's engine note: "the spec does not mandate a script
language."
Prior decisions frame the answer:
- ADR-027 §2.5 already fixed the shape: the Script Engine is a pluggable seam, interpreters are swappable engine services.
- The dependency-light core (SAD-001; the README contract: stdlib +
google/uuidonly) forbids embedding any interpreter inpkg/— the ADR-029 adapter tier is where interpreters live, andadapters/dtableproved that shape end to end (module discovery, depguard direction, external seam proof). - The expression layer (#74) is a different seam:
expression.Engineevaluates modeledFormalExpressions (conditions, gateways); the Script Engine executes a task's opaque source body. They stay separate — a FEEL adapter and a Lua adapter answer different questions.
2. Decision¶
2.1 The seam — multiple engines, format-routed by the core¶
A new engine service, the Script Engine, in the ADR-002 shape — with
one deliberate extension over the rule-engine precedent: more than one
script engine registers in a single gobpm engine, and the core routes
between them by the standard's own scriptFormat.
- Interface — three members: the
##-kind string (startup config and observability — the ScriptTask has noimplementationattribute, so the kind is engine identity only);Formats()— the enumerable list of MIME hints the engine interprets (enumerable, not a predicate: only a listable claim lets the router detect conflicts and print the routing table); and execute (run this script body against the read-only data surface, return the script's named outputs). The read surface is the sameservice.DataReaderwalk-up every in-process functor receives. - The router is core machinery (pure Go — the stdlib-light contract
holds): registered engines fold into a format → engine map at
construction. Two engines claiming the same format is a loud
construction-time error naming both kinds — never silent shadowing.
The startup config prints the routing table (format → engine kind), so
which interpreter answers which
scriptFormatis operator-visible. - Routing at the task: the task's
scriptFormat(normalized case-insensitively) selects the engine; a format no registered engine claims is a classified error that lists the formats actually registered — self-explaining, never a silent skip. - Wiring — the five-point pattern with a registry twist: the config
holds the engine set;
WithScriptEngine(e)is repeatable — each call registers another engine (nil rejected; format conflicts rejected); theEngineRuntimeaccessor exposes the routing surface; the startup-config line prints the table.
2.2 The default — the empty registry, loud¶
The in-core default is the empty registry (shown as ##None in the
startup config): no formats are claimed, and executing any Script Task
fails with a classified error telling the operator exactly what to do
(register an engine — e.g. adapters/lua — via WithScriptEngine).
ADR-027 §2.5 anticipated a default "if any"; here the honest answer is
none:
- an interpreter in core breaks the stdlib-light contract (§1);
- a Go-functor "script" default would duplicate
gooper/gorulesunder a false name — a script engine that cannot run script text is a lie.
The empty registry keeps the never-nil accessor convention and turns a model that needs scripts into a loud, self-explaining failure instead of a nil-dereference, through the ordinary fault machinery — the zero case of the general multi-engine mechanism, not a special object.
2.3 Execution semantics — invoke, complete, commit named outputs¶
Exactly the standard's clause on the existing task machinery:
- On activation: the task's
scriptFormatroutes to the registered engine (§2.1) and the script runs synchronously (the token waits, as for an in-process operation). - On return: the script's result is a set of named outputs (name → value). Each output commits as its own Ready datum through the execution frame — the script-natural semantics (a script sets variables), and deliberately simpler than the Business Rule Task's DMN fold: scripts have no hit policies and no multi-row results, so per-name commit is the whole mapping. An empty result commits nothing. Reads happen only through the read surface; writes only through the returned outputs (the functional posture every in-process seam shares).
- On failure: a script error fails the task through the ordinary fault path — boundaries, loops, compensation apply unchanged. An engine may map a script-level convention onto typed business errors (an adapter concern, conceived per adapter); absent that, a script failure is a technical task failure.
- Observability: a new
Scriptfact kind, mirroring the rule engine's (SRD-060 FR-6 precedent): an Executed fact (engine kind, format, output count/names — never payload values) and a Failed fact (the decision-level context before the error rides the fault machinery).
2.4 The batteries adapter — Lua (adapters/lua)¶
The first interpreter is Lua via gopher-lua — chosen over the
proposed alternative (Python) deliberately:
- pure Go, no cgo — embedding CPython requires cgo and a system
Python, breaking static builds and cross-compilation (the deployment
posture a Go library must not impose);
gopher-luais a mature, MIT, pure-Go VM; - Lua is the industry's embeddable script language — small surface, designed for hosting, sandboxable;
- a Python-dialect sibling (
starlark-go, pure Go, deterministic) is the honest path if Python syntax is wanted — a futureadapters/starlarkbehind the same seam; real CPython is rejected outright.
Adapter conception (landed by its own SRD):
- Formats: the MIME hints
text/x-luaandapplication/x-lua(plus the bareluaconvenience), normalized case-insensitively. Kind:##Lua. - Data exposure — lazy and fail-loud: the script sees a read-only
datatable whose lookups resolve through theDataReaderon access (a metatable, not an eager snapshot). Reading an absent datum is a script error (the dtable §2.5 posture — a typo fails loud), with an explicithas(name)probe for optional data. Values map Go↔Lua (bool/number/string; structured values to tables) — an unmappable value is a loud error, never a silent nil. - Results: the script's returned table (string keys → mappable values) becomes the named outputs; returning nothing commits nothing; a non-table return is an error (outputs are named, per §2.3).
- Sandbox by default: only the safe libraries load (base, table,
string, math);
io/osstay out unless the embedder opts in; execution honors the task context (cancellation/timeout through the VM's context hook). The sandbox posture is the default precisely because script bodies are the first artifact likely to arrive from outside the Go codebase.
2.5 Engine notes (deviations & choices)¶
| Choice | Standard position | Engine choice |
|---|---|---|
| Script language | not mandated; scriptFormat is a MIME hint |
multiple registered engines, core-routed by an enumerable format→engine map (conflicts loud at construction); Lua as the batteries adapter; the default is the loud empty registry (§2.1/§2.2/§2.4) |
| Result mapping | silent (no result model on ScriptTask) | the script's named outputs commit per-name as Ready data — no fold, no result variable (§2.3) |
| Script data access | silent | read-only reader surface, lazy data table, absent datum fails loud with a has() probe (§2.4) — the fail-loud house rule over Lua's nil convention |
| Failure typing | task faults | technical failure by default; typed business-error conventions are per-adapter (§2.3) |
| Sandbox | — | safe-libraries-only by default; embedder opt-in for more (§2.4) |
3. Grounding¶
| Claim | Source |
|---|---|
| ScriptTask semantics: invoke on activation, complete on the script's completion | the vendored extract, semantics/tasks.md §ScriptTask (file source: BPMN 2.0 §13.3.3, p430) |
script + scriptFormat (MIME) carry language and source; no mandated language; both 0..1 in the metamodel |
the extract's ScriptTask engine note (semantics/tasks.md:65) + the element model (elements/activities.md §ScriptTask: scriptFormat attr 0..1, script child 0..1) |
| The Script Engine is pluggable, same shape as the rule engine | ADR-027 v.1 §2.5 (the standing decision) |
| Core stays stdlib+uuid; interpreters can't live there | SAD-001 v.1 (the library posture); ADR-003 §4.4 |
| The adapter-tier engine shape (module, seam proof, deploy split) | ADR-029 v.1 (landed: adapters/dtable) |
The expression seam is separate (FormalExpression evaluation) |
ADR-011 lineage; pkg/model/expression (landed) |
| The observability mirror (per-engine facts, masking rule) | SRD-060 v.1 FR-6 (KindRules precedent) |
4. Alternatives considered¶
- Embed an interpreter in core (Lua or otherwise). Rejected — breaks the stdlib-light contract; the adapter tier exists for exactly this.
- Real Python (CPython via cgo). Rejected outright — cgo + system
Python destroys static builds, cross-compilation and the embedding
story; the deployment cost lands on every consumer of the adapter.
Starlark (
starlark-go) is the recorded pure-Go path to Python syntax, as a future sibling adapter — noted, not built. - Reuse the expression engine for scripts. Rejected — different
contracts: expressions are modeled, typed, single-valued evaluations;
scripts are opaque multi-statement bodies with named outputs. Conflating
them couples #74's evolution to task execution (the reason ADR-029
rejected
FormalExpressionconditions too). - A Go-functor default engine. Rejected — duplicates
gooperunder a false name and cannot execute script text (§2.2); the null default is honest. - The BRT rows-and-fold result model for scripts. Rejected — scripts have no hit policies; named outputs committed per-name are the natural and simpler mapping (§2.3).
5. Consequences¶
Positive: the last silent conformance task type gains execution; the seam completes the ADR-027 §2.5 promise with the same proven shape; interpreters stay out of the core; the Lua adapter gives embedders real scripting with a sandbox-by-default posture; a second interpreter (Starlark) is one adapter away.
Negative / cost: a third engine-service seam to keep stable; a new
adapter module with an external dependency (gopher-lua) to track; the
null default means zero-config models with Script Tasks fail at runtime
(loudly, by design) rather than at registration — registration-time
validation would need model-level knowledge of the wired engine, noted as
a possible future guard.
Follow-ups this conception sets up: adapters/starlark (the
Python-dialect sibling — registering beside Lua in the same engine, the
multi-engine mechanism's first natural exercise); a registration-time
"format supported" validation hook; per-adapter typed business-error
conventions.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 (Accepted) | 2026-07-25 | Ruslan Gabitov | Accepted with both landing SRDs: the multi-engine seam, registry routing, the ScriptTask semantics and the ##None default landed first (the seam SRD); the Lua batteries adapter (adapters/lua) completed the conception (its SRD). Starlark remains the noted sibling. |
| v.1 | 2026-07-25 | Ruslan Gabitov | Draft conception, fulfilling ADR-027 §2.5. The Script Engine seam: format-routed (the standard's scriptFormat MIME hint validates against the engine's format predicate; unsupported = loud classified error), executing an opaque script body against the read surface and returning named outputs committed per-name (no fold — scripts set variables). Multiple script engines register in one gobpm engine (repeatable WithScriptEngine): the core folds their enumerable Formats() claims into a format→engine routing map — claim conflicts are loud construction-time errors, the startup config prints the routing table, and an unclaimed format errors listing what IS registered. The default is the loud empty registry, shown as ##None (stdlib-light core; a Go-functor default would duplicate gooper dishonestly). Lua (gopher-lua, pure Go) is the batteries adapter — real CPython rejected outright (cgo), Starlark recorded as the future Python-dialect sibling. Adapter conception: lazy fail-loud data table with a has() probe, table-return results, sandbox-by-default (safe libs only, context-honoring). A Script observability kind mirrors the rule engine's facts. Implementation rides the accompanying SRDs (the seam+task, then the Lua adapter). |