SRD-064 — The Script Engine seam (multi-engine) and the Script Task¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-25 |
| Owner | Ruslan Gabitov |
| Implements | ADR-031 v.1 §2.1–§2.3 + §2.5 (the seam, the registry/router, the empty-registry default, the task semantics, the observability mirror; §2.4 — the Lua adapter — rides SRD-065) |
| Upstream | ADR-027 v.1 §2.5 (the fulfilled promise), ADR-002 v.2 §4.1/§4.2, ADR-012 v.1 |
| Refines | SRD-060 v.1 (the rule-engine seam landing this mirrors: five-point wiring, task Exec shape, per-engine facts) — sideways |
Lands ADR-031's core half: pkg/script (the Engine contract + the
multi-engine format Registry), the five-point thresher/renv wiring with
repeatable registration, the ScriptTask rebuilt to the house pattern with
per-name output commits, and the Script observability kind. The Lua
interpreter is SRD-064's; this SRD proves everything with test engines.
§1 Background¶
- The model stub is bare (
pkg/model/activities/script_task.go:ScriptTask{ScriptFormat, Script string; task}— exported dead fields, no constructor/Exec/asserts, the exact SRD-060 §1 starting shape); the enum entry exists (pkg/model/flow/activity.go:ScriptTask TaskType = "ScriptTask"). - The five-point wiring template is landed twice (expression, rules):
thresherConfig+defaultConfig()+ option +EngineRuntimeaccessor (implementors:pkg/thresher/options.gothresherConfig,internal/enginert/enginert.goRuntime) +logStartupConfigmodule(...)lines (pkg/thresher/thresher.go), withmake gen_mock_filesregeneratingmockrenv. - The per-engine observability mirror is landed (
KindRules, SRD-060 FR-6): kind + phases +Attr*keys + echo levels; node-side facts route through the instance emission point (theexecEnv.Reporteroverride). - The commit helpers are landed (FIX-026):
data.ReadyValueParameterbuilds a Ready datum from a name +data.Value— the per-name output commit is one call per output. - Conformance row 5 (
ScriptTaskexecution, 🟡 model-only, #87) flips at SRD-065 when the batteries interpreter and the example land — this SRD makes the row implementable by registration.
§2 Requirements¶
§2.1 Functional¶
- FR-1 —
pkg/script: the contract and the Registry. script.Outputs— the named results:map[string]data.Value.script.Engine—Type() string(the##-kind),Formats() []string(the enumerable MIME claims, non-empty for a real engine),Execute(ctx, format, script string, r service.DataReader) (Outputs, error).script.Registry— the core router, itself ascript.Engine:NewRegistry(engines ...Engine)folds claims into a format→engine map, normalizing case (strings.ToLower, trimmed); a nil engine, an engine with no formats, an empty format, or a duplicate claim (error names both engine kinds and the format) reject construction.Type()="##None"when empty, else the joined kinds ("##Lua+##Starlark");Formats()= the sorted claim list;Executeroutes by the normalized format — an unclaimed format is a classified error listing the registered formats (empty registry: the wire-an-adapter message, ADR-031 §2.2).- FR-2 — five-point wiring, registry-shaped.
thresherConfiggainsscriptEngines []script.Engine;WithScriptEngine(e)is repeatable (nil rejected; each call appends);thresher.Newbuilds the registry once viascript.NewRegistry— claim conflicts surface as New errors (construction-time, loud); the built registry lands in the config;renv.EngineRuntimegainsScriptEngine() script.Engine(never nil — the empty registry default);internal/enginert.Runtimegains aWithScriptRegistry(reg)builder (nil ignored, its default the empty registry) — conflicts always surface throughNewRegistry, which the caller runs;logStartupConfigprints thescriptEnginemodule line (the registry's aggregated kind) plus one routing line per format (scriptFormat text/x-lua: ##Lua). Mocks regenerated. - FR-3 — the ScriptTask model. The stub rebuilt to the house pattern:
unexported
scriptFormat,scriptfields;NewScriptTask(name, format, script string, opts ...options.Option)— all three trimmed-non-empty (an engine choice: the metamodel carries both as 0..1, but a programmatic model with a scriptless ScriptTask is a modeling error — fail fast);ScriptFormat()/Script()getters;Node()/Clone()/TaskType()(the enum entry exists) and the three interface asserts. - FR-4 — execution (ADR-031 §2.3).
Exec(ctx, re): nil-guardre;outs, err := re.ScriptEngine().Execute(ctx, st.scriptFormat, st.script, re); an error reports theScript/Failedfact and returns (the ordinary fault path — routing misses included); a non-empty result commits per name in sorted order (deterministic — map iteration is not) viadata.ReadyValueParameter+re.Put, wrapped with the task identity on failure; then theScript/Executedfact;selectOutgoing(ctx, re). - FR-5 — observability.
KindScriptwithPhaseExecuted(new) and the reusedPhaseFailed; details:AttrScriptFormat(new),AttrImplementation(the answering engine's kind — forExecuted, the routed engine; for a routing miss, the registry's aggregate),AttrOutputCount(new; names and counts only — the masking rule). Echo:KindScriptat Debug,{KindScript, PhaseFailed}at Warn.
§2.2 Non-functional¶
- NFR-1 — validate-all-params; no
Must*calls in library paths (the muststyle guard applies —pkg/scriptsits underpkg/). - NFR-2 — stdlib-light:
pkg/scriptimports core packages only. - NFR-3 — coverage:
make cigreen; diff-coverage ≥95% (aim 100%); touched functions ≥80%.
§3 Models (shapes)¶
// pkg/script/script.go (FR-1)
type Outputs map[string]data.Value
type Engine interface {
Type() string
Formats() []string
Execute(ctx context.Context, format, script string,
r service.DataReader) (Outputs, error)
}
// pkg/script/registry.go (FR-1)
func NewRegistry(engines ...Engine) (*Registry, error)
func (reg *Registry) Type() string // "##None" | "##Lua+##Starlark"
func (reg *Registry) Formats() []string // sorted claims
func (reg *Registry) Execute(...) // routes; unclaimed = loud
func (reg *Registry) EngineFor(format string) (Engine, bool) // the
// routed engine, for the task's Executed-fact implementation detail
// pkg/model/activities/script_task.go (FR-3)
type ScriptTask struct {
scriptFormat string
script string
task
}
func NewScriptTask(name, format, script string,
opts ...options.Option) (*ScriptTask, error)
Wiring points (FR-2, all previously landed and re-verified): the config
struct/defaults/option block in pkg/thresher/options.go, the accessor
block in pkg/renv/engineruntime.go, the builder/accessor pair in
internal/enginert/enginert.go, the module(...) block in
pkg/thresher/thresher.go.
§4 Analysis & decisions¶
- §4.1 The registry is an
Engine. The task talks to one interface; composition is invisible to it.EngineForexists solely so theExecutedfact can name the routed engine's kind rather than the aggregate (FR-5) — the task callsExecutefor the work itself. - §4.2 Repeatable option, build-at-New. Options accumulate; the
conflict check runs once in
thresher.Newwhere an error can surface (options.go's error-style idiom).enginert's builder-style can't error, so it takes a pre-built registry —NewRegistryremains the single conflict authority. - §4.3 Sorted-name commits. Go map iteration would make commit order (and any commit failure) non-deterministic across runs; sorting output names makes failures reproducible. No semantic ordering is implied — each output is an independent datum.
- §4.4 Required format/script at construction. The metamodel's 0..1 is an interchange affordance; programmatically a scriptless ScriptTask is a bug — fail-fast at build (the decisionRef precedent, SRD-060 §4.3-adjacent). Recorded as an engine note in ADR-031 §2.5's spirit.
§6 Test scenarios¶
| # | Test | Verifies |
|---|---|---|
| T-1 | registry unit (pkg/script) |
FR-1: two-engine routing (case-insensitive formats); duplicate-claim rejection naming both kinds; nil/formatless engine rejected; empty registry ##None + the wire-an-adapter error; unclaimed format lists registered formats; Type() aggregation; EngineFor |
| T-2 | task model (pkg/model/activities) |
FR-3: constructor validation (empty name/format/script), getters, TaskType(), Clone carries format+script |
| T-3 | Exec vs mock renv + stub engine |
FR-4/FR-5: Execute called with format+script; outputs committed per-name in sorted order (a multi-output script; the Put matcher sees the slice); empty outputs commit nothing; engine error → Script/Failed fact + task failure; the Executed fact carries format/kind/count |
| T-4 | wiring (pkg/thresher + internal/enginert) |
FR-2: default is the empty registry (##None in the startup log + routing lines absent); repeatable WithScriptEngine registers two engines (routing lines printed); a claim conflict fails thresher.New; WithScriptEngine(nil) rejected; enginert default/WithScriptRegistry |
| T-5 | e2e (pkg/thresher, test engines) |
the full path: two stub engines registered, a ScriptTask routes to the right one by format, its outputs are read downstream; an unclaimed format faults the instance loud; the empty-registry default faults loud |
§7 Milestones¶
- M1 —
pkg/script. FR-1; T-1.feat(script): the Script Engine contract and the multi-engine format registry. - M2 — the task + the wiring + the facts. FR-2…FR-5; mocks regen;
T-2…T-4.
feat(activities): Script Task on the Script Engine seam. - M3 — e2e + partial doc sync. T-5; CHANGELOG (seam half); the
conformance row 5 note updated to "seam landed; interpreter rides
SRD-065" (the flip itself, README/tour and the example ride SRD-065).
feat: Script Task — e2e and seam doc sync.
§8 Cross-doc¶
- Implements ADR-031 v.1 §2.1–§2.3/§2.5.
- Upstream: ADR-027 v.1 §2.5, ADR-002 v.2, ADR-012 v.1.
- Sideways: SRD-060 v.1 (the mirrored landing).
- Part of #87 (Script Task — the issue's second half; with SRD-065 it closes) and the #74 boundary note: the expression seam is untouched.
§9 Definition of Done¶
- [ ] FR-1…FR-5 implemented; every §6 test exists and passes.
- [ ]
make cigreen; diff-coverage ≥95% (aim 100%); touched functions ≥80%. - [ ] The empty-registry default fails loud (T-5) and a claim conflict
fails
thresher.New(T-4). - [ ] §10 filled; CHANGELOG synced; conformance row 5 note updated.
§10 Implementation summary¶
Landed on feat/script-task in three milestones (after the doc commits
a322c39 ADR-031 / 1fb9836 this SRD):
- M1
b309789—pkg/script. The Engine contract (##-kind, enumerableFormats(),Execute→Outputs) and the Registry router (case-normalized claim map; loud duplicate-claim errors naming both kinds;##Noneempty default with the wire-an-adapter message; unclaimed formats listing the claims;EngineForfor fact attribution). Coverage 100.0%. - M2
089cefe— the task + wiring + facts.KindScript+PhaseExecuted+ the format/count attrs (Failed@Warn);EngineRuntime.ScriptEngine(); the repeatableWithScriptEnginewith the registry built (and conflicts surfacing) inthresher.New; the startup routing table;enginert.WithScriptRegistry; the rebuiltScriptTask(required name/format/script; sorted per-name commits viaReadyValueParameter; routed-kind fact attribution). Alignment housekeeping the new field surfaced (thresherConfig/Thresher order, a stale nolint dropped). - M3
144ad95— e2e + seam doc sync. Two-engine live routing (case-insensitive), unclaimed-format and##Nonefaults surfaced throughWaitCompletion, fact attribution per format; CHANGELOG; conformance row 5 → "seam landed" (the flip rides SRD-065).
Verification: post-commit make ci exit 0; diff-coverage 98.6% of
211 changed coverable lines (min 95; script_task.go/registry.go/
options.go/thresher.go all 100%); full -race suite green;
golangci-lint 0 issues; 0 Must* calls in the new library code (the
muststyle guard covers pkg/script).
Delta vs draft: none — the landed shapes match §3.
Open questions¶
None — §4 resolves the design points inline.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 (Accepted) | 2026-07-25 | Ruslan Gabitov | Landed and accepted — §10 filled (M1 b309789, M2 089cefe, M3 144ad95); ADR-031 flips with SRD-065's landing. |
| v.1 | 2026-07-25 | Ruslan Gabitov | Initial draft — lands ADR-031's core half: pkg/script (Engine with enumerable Formats(), Outputs, the Registry router — loud claim conflicts, ##None empty default, unclaimed-format errors listing the claims), the registry-shaped five-point wiring (repeatable WithScriptEngine, conflicts surfacing in thresher.New, per-format startup routing lines, enginert.WithScriptRegistry), the ScriptTask rebuilt (required name/format/script, per-name sorted output commits via ReadyValueParameter), and KindScript facts (Executed/Failed, format/kind/count details). Three milestones; the Lua interpreter, the example, and the row-5 flip ride SRD-065. |