SRD-004 — Extension Skeleton (minimal/default implementation of ADR-002)¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-06-09 |
| Owner | Ruslan Gabitov |
| Implements | ADR-002 v.1 Extension Architecture |
| Refines | SAD-001 v.1 §11 Extension Model |
This SRD lands the foundational extension skeleton of ADR-002: every extension contract defined in pkg/, each with a bundled default (default behaviour only — no production adapters), the functional-options assembly on thresher.New, the EngineRuntime/RuntimeEnvironment split, and the startup log line — wired so a zero-option engine runs today's BPMN end-to-end. Completing this SRD closes ADR-002's §7 acceptance gate (the defaults-only rows) and flips ADR-002 → Accepted.
1. Background & motivation¶
1.1 Current state (verified against the code)¶
- Engine constructor has no injection point —
func New(id string) (*Thresher, error)(pkg/thresher/thresher.go:116); single-arg, no options. RuntimeEnvironmentis internal with four methods —internal/renv/renv.go:12: embedsscope.Scope;InstanceID() string;EventProducer() eventproc.EventProducer;RenderRegistrator() interactor.Registrator.Instancealready implements it —internal/instance/instance.go:821(var _ renv.RuntimeEnvironment = (*Instance)(nil)), withInstanceID()/EventProducer()/RenderRegistrator()methods (instance.go:803/808/813).EventHubis internal —internal/eventproc/eventproc.go:47(EventHub,EventProducer,EventProcessor); not externally implementable.- Human interaction is internal —
internal/interactor(Registrator,Rendererecosystem); reached viaRenderRegistrator(). - Expressions are a model type —
pkg/model/data/expression.go:72(FormalExpression); evaluated directly, not behind an engine-level interface. - No infrastructure extension points —
Repository,Logger,Tracer,MetricsRecorder,Clock,MessageBroker,AuthorizationProvider,WorkerDispatcherdo not exist anywhere (per ADR-002 §1).
1.2 Why¶
ADR-002 (reconciled to ADR-001 v.4) is the agreed extension architecture, but none of it is built. Per ADR-002 §5/§7, the departures land together, at minimal/default behaviour, in this one foundational SRD; ADR-002 flips to Accepted when this SRD's §7 tests pass. Production adapters and per-interface depth are later, separately-gated SRDs.
2. Goals & scope¶
2.1 Goals (in scope)¶
- G1. Define all 9 extension contracts in
pkg/(per ADR-002 §4.2):Logger(slog-satisfiable core interface —*slog.Loggerimplements it directly), OTel-shapedTracer/MetricsRecorder(modeled on the OTel API but core does not import OTel — SAD-001 G2),Clock,Repository,MessageBroker,ExpressionEngine,AuthorizationProvider,WorkerDispatcherinterfaces.EventHubstays internal — execution plumbing, not an extension point (no substitution use-case; ADR-002 §4.2).TaskDistributoris deferred — theinternal/interactorhuman-interaction cluster is interlocked and forces a model→tasks layering choice, so its promotion (and theRegistrator → TaskDistributorrename) rides a dedicated human-interaction ADR (ADR-001 v.4 §9), not this skeleton. - G2. Ship a bundled default in core for each:
slog.Default()Logger · no-opTracer(with an in-memory recent-spans ring available as an opt-in) · in-memory queryable, series-cappedMetricsRecorder(Snapshot()for tests/diagnostics) · wall-clockClock· in-memory non-durableRepository· in-memory inboxMessageBroker· Go-nativeExpressionEngine(wrapping the existing evaluator) · allow-allAuthorizationProvider· in-processWorkerDispatcher. (EventHubstays internal — not a public-contract default; the internal interactor cluster is deferred.) - G3. Functional-options assembly —
thresher.New(id string, opts ...Option) (*Thresher, error);defaultConfig()wires all defaults; eachWithXxxoverrides one; last-write semantics; noNewDefault. Zero-optionNew(id)produces a working engine. - G4. Startup log line — one INFO
thresher.startingrecord listing the resolved wiring (per ADR-002 §4.4.1). - G5. Factor
EngineRuntime+ extendRuntimeEnvironment(ADR-002 §4.3): define the engine/server-levelEngineRuntimeinterface (the resolved services) implemented byThresher, promoted topkg/(public, path per ADR-003);RuntimeEnvironmentstays ininternal/renv(it embeds internalscope.Scope/EventProducer/RenderRegistrator) and embeds the publicEngineRuntime+ instance-local;RenderRegistrator()is retained as-is (human-interaction promotion deferred — ADR-001 v.4 §9);Instanceembeds the Thresher'sEngineRuntime(engine methods promoted — no per-method delegates) and adds its instance-local methods; track call sites stay uniform (t.inst.X()). - G6. Wire into current execution the extensions today's BPMN actually exercises: route
FormalExpressionevaluation throughExpressionEngine; source time fromClock(timer handling) instead oftime.Now; use the configuredLogger. (EventHubis internal and already wired.) Engine runs the current element set unchanged. - G7. No external behaviour change for implemented elements (None Start/End, Service/User tasks, Exclusive gateway, sequence flows incl. conditions/default, timer events): existing tests pass unchanged and
make ciis green. The previously-working example (examples/simple-timer) still runs.examples/timer-eventandexamples/basic-processhave a pre-existing failure that predates SRD-004 (broken on master — event-start registration vs theCreatedlifecycle guard, and a blocking-task deadlock) — tracked by FIX-002 and out of this SRD's gate.
2.2 Non-goals (explicitly deferred)¶
- N1. Production adapters (
adapters/*— postgres, otel, casbin, FEEL, redis/nats brokers, …) → later SRDs per ADR-002 §4.6. - N2. Execution hook-sites for the not-yet-exercised services.
Repository,MessageBroker,AuthorizationProvider,WorkerDispatcherare defined + defaulted + accessible via the engine andRuntimeEnvironment, but their call-sites are not wired in this SRD — no current BPMN feature needs them yet: Repositorycheckpoint/load/rehydrate call-sites → the Persistence & State ADR (ADR-001 v.4 §4.7).MessageBrokercorrelation routing → the message-correlation SRD.AuthorizationProviderenforcement at sensitive ops → an authz SRD.WorkerDispatcherremote dispatch → the distribution SRD (per SAD-001 §13.2). The skeleton makes them present and overridable; invoking them is out of scope.- N3. Final
pkg/package paths — ADR-003 owns the exact layout. This SRD places interfaces per ADR-003's proposed per-concern layout; paths are provisional until ADR-003 lands (a later move is a mechanical rename). - N4. The
RuntimeAwareadapter-injection hook (ADR-002 §3.5 Pattern C / §8.3) — the skeleton definesEngineRuntimeandRuntimeEnvironment, but has no adapters to inject into, so theUseRuntime(EngineRuntime)hook and its assembly-time wiring land with the first real adapter. - N5. Adapter contract-test helpers, optional side-capability interfaces (
Starter/Stopper/…), cluster awareness (ADR-002 §8.3/§8.4) — land with the first real adapter. - N6. Human-task interaction promotion. The
internal/interactorcluster (Registrator/Interactor/RenderController) stays internal; theRegistrator → TaskDistributorrename and any engine-level exposure ride a dedicated human-interaction ADR (ADR-001 v.4 §9). The skeleton ships 10 contracts, and the existing instance-levelRenderRegistrator()is untouched.
3. Requirements¶
3.1 Functional¶
| # | Requirement | Acceptance |
|---|---|---|
| FR-1 | The 9 contracts exist in pkg/ (G1). EventHub stays internal (ADR-002 §4.2); TaskDistributor deferred (human-interaction ADR). |
grep finds each interface under pkg/; build passes; internal/ impls import the pkg/ interfaces. |
| FR-2 | Each contract has a bundled core default (G2). | A default value exists and satisfies its interface; constructed by defaultConfig(). |
| FR-3 | thresher.New(id, opts ...Option); zero-option works; WithXxx overrides; last-write; no NewDefault. |
New("x") runs; WithLogger(a),WithLogger(b) ⇒ b; no NewDefault symbol. |
| FR-4 | One INFO thresher.starting log line lists every resolved engine extension by impl type. |
Capture-logger test: exactly one record, key thresher.starting, attrs per ADR-002 §4.4.1. |
| FR-5 | EngineRuntime (engine services) defined in public pkg/renv, implemented by Thresher; RuntimeEnvironment stays internal, embeds it + instance-local (incl. the retained RenderRegistrator()); Instance embeds the Thresher's EngineRuntime. |
var _ renv.EngineRuntime = (*Thresher)(nil) (public) and var _ internalrenv.RuntimeEnvironment = (*Instance)(nil). |
| FR-6 | Instance embeds the Thresher's EngineRuntime (engine methods promoted, no per-method delegates); track reaches services via its one *Instance. |
Per-method test: instance.Logger() (etc.) == the engine's configured value. |
| FR-7 | ExpressionEngine, Clock, Logger are wired into current execution (G6): FormalExpression evaluated via ExpressionEngine; timer time via Clock. (EventHub is internal and already wired.) |
Override-and-observe tests: a custom ExpressionEngine/Clock is the one used during execution. |
| FR-8 | Repository/MessageBroker/AuthorizationProvider/WorkerDispatcher are defined, defaulted, and reachable via the engine/RE, but not invoked by execution (N2). |
They're constructable and accessible (instance.Repository() etc.); no execution call-site references them yet. |
| FR-9 | No regression for implemented elements (G7). | Existing internal/instance + engine tests and examples/* pass unchanged; make ci green. |
3.2 Non-functional¶
| # | Requirement | Acceptance |
|---|---|---|
| NFR-1 | Race-free under the detector. | make ci (race-gated) green. |
| NFR-2 | Touched/created files meet the coverage standard (≥80%, aim 100%; gated by covercheck). |
make cover-check PASS on the diff. |
| NFR-3 | core gains no non-stdlib runtime dependency (SAD-001 G2). Upheld even for telemetry: Tracer/MetricsRecorder are OTel-shaped but core does not import OTel (ADR-002 §4.2); the real OTel types live in adapters/otel/. |
go mod graph shows no new external core dep (defaults are stdlib-only; slog is stdlib; no go.opentelemetry.io/* in core). |
| NFR-4 | Visible-by-default observability preserved (Logger default slog.Default()). |
Zero-option engine logs to the default handler. |
4. Design & implementation plan¶
4.1 Shapes (illustrative; exact pkg/ paths per ADR-003)¶
// Engine-level config holds the resolved extensions (one per interface).
type thresherConfig struct {
logger Logger // slog-satisfiable interface; default slog.Default()
tracer Tracer // OTel-shaped, core-defined (no OTel import); default no-op
metrics MetricsRecorder // default = in-memory queryable, series-capped registry
clock Clock
repository Repository
msgBroker MessageBroker
exprEngine expression.Engine
authz AuthorizationProvider
dispatcher WorkerDispatcher
// EventHub is NOT here — it stays internal (ADR-002 §4.2); the Thresher
// constructs its internal hub itself, not via an option.
}
type Option func(*thresherConfig)
func WithLogger(l Logger) Option { return func(c *thresherConfig) { c.logger = l } } // *slog.Logger satisfies Logger
// … one per extension …
func New(id string, opts ...Option) (*Thresher, error) {
cfg := defaultConfig() // all defaults wired
for _, o := range opts { o(&cfg) }
t, err := assemble(id, cfg)
if err != nil { return nil, err }
t.logStartupConfig() // §4.4.1
return t, nil
}
Per ADR-002 §4.3 the engine services are factored into an EngineRuntime interface that Thresher implements (returning the resolved thresherConfig values); RuntimeEnvironment (stays internal; only EngineRuntime is promoted to pkg/) embeds EngineRuntime + instance-local methods; Instance embeds the Thresher's EngineRuntime (engine methods promoted — no per-method delegates) and keeps its instance-local methods. Track call sites are unchanged in style (t.inst.Clock().Now()).
4.2 Milestones (each independently buildable + CI-green)¶
- M1 — Observability + Clock.
Logger(slog-satisfiable interface), OTel-shapedTracer/MetricsRecorder(no OTel import),Clockinterfaces + defaults:slog.Default()Logger, no-op Tracer, in-memory queryable series-capped Metrics registry, wall-clock Clock — plus an opt-in in-memory recent-spans ring Tracer for dev/tests. Pure leaf packages; no engine wiring yet. Default/conformance tests (incl. the registrySnapshot()+ series-cap behaviour). - M2 — Stateful leaves.
Repository,MessageBroker,AuthorizationProvider,WorkerDispatcherinterfaces + defaults (in-mem / in-mem inbox / allow-all / in-proc). Defined + tested; not yet invoked (N2). - M3 — ExpressionEngine.
ExpressionEngine(pkg/model/expressioninterface wrapping theFormalExpressionevaluator) + Go-native default (pkg/model/expression/goexpr). Define + default only; call-site routing is M5/G6.EventHubstays internal (ADR-002 §4.2) andTaskDistributoris deferred (ADR-001 v.4 §9) — neither is promoted, so M3 is a clean new-package addition with no importer churn. - M4 — Assembly.
thresherConfig+Option+WithXxx(one per extension) +New(id, opts...)refactor +defaultConfig()+logStartupConfig()(§4.4.1). NoNewDefault. - M5 — EngineRuntime + RuntimeEnvironment. Promote
EngineRuntime(engine services) to publicpkg/renv, implemented byThresher;RuntimeEnvironmentstays ininternal/renv, embeds the publicEngineRuntime, and retainsRenderRegistrator()as-is;Instanceembeds the Thresher'sEngineRuntime; redirect imports; wire ExpressionEngine + Clock into execution (G6). - M6 — Acceptance. ADR-002 §7 applicable suite (zero-option New e2e, options compose/override/last-write, startup log, Instance-implements-RE, delegates, default conformance, RE composition) + examples pass +
make cigreen +cover-checkPASS. Flip ADR-002 + SRD-004 → Accepted + RU twins.
Sequencing: leaves (M1/M2) and promotions (M3) define the contracts+defaults with no engine coupling; assembly (M4) wires them into New; RE extension (M5) exposes them through Instance and wires the executed ones; M6 verifies + accepts.
5. Verification (Definition of Done)¶
Maps to ADR-002 §7 (defaults-only rows; adapter-dependent rows deferred to the first adapter SRD):
| Test | Asserts |
|---|---|
| Zero-option New e2e | thresher.New("t") registers + runs a process to completion with all defaults. |
| Options compose / override / last-write | all WithXxx in random order ⇒ same state; each overrides its default; last write wins. |
| Startup config log | exactly one INFO thresher.starting with an attr per engine extension = wired impl type. |
| Thresher implements EngineRuntime; Instance implements RuntimeEnvironment | var _ renv.EngineRuntime = (*Thresher)(nil) and var _ renv.RuntimeEnvironment = (*Instance)(nil). |
| Engine-service delegates | instance.X() == engine config's X, per method. |
| Default conformance | the in-memory Repository default (and others) pass a default-behaviour test. |
| Executed-extension wiring | a custom ExpressionEngine/Clock is the one used during execution (FR-7). |
| No regression | existing suite passes unchanged; examples/simple-timer runs; make ci green; cover-check PASS. (timer-event/basic-process pre-broken — FIX-002, out of scope.) |
DoD: all FR/NFR satisfied; the table above green; make ci + cover-check green; ADR-002 §7 (applicable rows) satisfied. Met on 2026-06-09 — ADR-002 + SRD-004 flipped to Accepted. RU twins deferred (batched until the FIX-002 / example work settles, per the bilingual-docs policy).
6. Risks & regressions¶
- RuntimeEnvironment move (
internal/renv→pkg/renv) ripples imports. Mitigation: M5 is its own milestone; redirect all importers;make cigates. - ExpressionEngine indirection changes evaluation path. Mitigation: default wraps the existing evaluator; FR-7 override test + no-regression suite (G7).
- Scope creep into N2 hook-sites. Repository/MessageBroker/AuthZ/WorkerDispatcher are define-and-default only; resist wiring call-sites here.
- Provisional package paths (ADR-003). Mitigation: paths are per ADR-003's proposed layout; a later move is mechanical.
coredependency creep. Mitigation: all defaults stdlib-only; NFR-3go mod graphcheck.
7. Implementation summary¶
Landed on branch feat/extension-skeleton, one commit per milestone:
- M1
91a2fdf—pkg/observability(Logger/Tracer/MetricsRecorder+noop/memmetrics/memtrace) +pkg/clock(Clock+syscl/clocktest). - M2
8d7e291—pkg/repository(+memrepo),pkg/messaging(MessageBroker+membroker),pkg/auth(+allowall),pkg/tasks(WorkerDispatcher+localdispatcher); bounded defaults, defined-but-not-invoked (N2). - M3
ef77485—pkg/model/expression.Engine(+goexprdefault). - M4
beaf13c—pkg/thresherfunctional options (nineWithXxx) +defaultConfig+New(id, opts...)+ startup log; nil-option rejection hardened ine5cb184. - M5
ffc534b—pkg/renv.EngineRuntime(public);RuntimeEnvironmentembeds it (internal);thresherConfigandInstanceimplement/embed it; the EventHub + timer waiters are injected with it;ExpressionEngine+Clockwired into the exclusive-gateway and timer call-sites.internal/enginertprovides a default runtime for tests.
Deviations from the original plan (all agreed in-flight): EventHub stays internal (not an extension point); human-interaction TaskDistributor deferred to its own ADR (N6); EngineRuntime is a shared value injected into instances and the EventHub (Solution B, no import cycle); telemetry defaults are cost-tiered (in-mem metrics on by default, no-op tracer); the bounded-in-memory-defaults principle (ADR-002 §4.2).
V-results (2026-06-09): make ci green — tidy / lint (0 issues × 6 modules) / build-all (incl. examples) / race test-all / cover-check / govulncheck. Diff-coverage 99.5% of changed lines (the 3 uncovered are an unreachable eventhub.New error branch). ADR-002 §7 defaults-only suite green. examples/simple-timer runs end-to-end. examples/timer-event and examples/basic-process are pre-broken (FIX-002) — excluded from G7.
8. References¶
- ADR-002 v.1 Extension Architecture — §3.5 cross-adapter composition (EngineRuntime / Pattern C), §4.2 catalogue, §4.3 EngineRuntime + RuntimeEnvironment, §4.4 assembly, §4.5 defaults, §5 departures, §7 acceptance gate (this SRD closes the defaults-only rows), §8.3
RuntimeAware. - ADR-001 v.4 Execution Model — §4.7 runtime invariants (Repository target); §4.3 event stream (EventHub/Logger consumers).
- ADR-003 v.1 Module Layout — final
pkg/paths (provisional here). - SAD-001 v.1 §11 Extension Model; §9.2 multi-module; G2 minimal-core-deps.
- Existing code:
pkg/thresher/thresher.go:116;internal/renv/renv.go:12;internal/eventproc/eventproc.go:47;internal/interactor/;pkg/model/data/expression.go:72;internal/instance/instance.go:803-821.
9. Open questions¶
- Observability package grouping — one
pkg/observability(Logger/Tracer/MetricsRecorder) vs separatepkg/logger,pkg/tracer,pkg/metrics? ADR-003 §3.2 prefers "one subpackage per cohesive concern". Resolved provisionally: group aspkg/observabilityfor the cohesive sinks; final call rides ADR-003. Confirm at M1. MetricsRecorderplacement? Resolved:MetricsRecorder()is a method onEngineRuntime(ADR-002 §4.3); sinceRuntimeEnvironmentembedsEngineRuntime, it is reachable both engine-side (Thresher) and instance-side (Instance) with no special-casing.ExpressionEngineinterface shape — minimalEvaluate(expr data.FormalExpression, scope scope.Scope) (any, error)? Resolved provisionally: mirror the current evaluator's call signature so the default is a thin wrapper; pin exact signature at M3 from the call-sites.- Default telemetry implementation — no-op vs visible vs log-backed? Resolved (design discussion): defaults differ by signal cost (ADR-002 §4.2). Metrics default to an in-memory, series-capped, queryable registry (visible by default per the observability policy;
Snapshot()makes tests trivial — no logtel needed). Tracing defaults to no-op (a span is a per-event allocation, inert without a backend) with an in-memory recent-spans ring as a one-line opt-in. A persistent SQL telemetry sink is a future production adapter (adapters/sqlstore), never a core default. The earlier logging-backed telemetry idea is dropped (it turns metrics into log text that must be parsed back).
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 (Accepted) | 2026-06-09 | Ruslan Gabitov | Accepted. All milestones (M1–M5) landed; make ci green; ADR-002 §7 defaults-only suite passed; diff-coverage 99.5%. G7 met for existing tests + simple-timer; timer-event/basic-process pre-broken, scoped out to FIX-002. RU twin deferred (batched). Pre-acceptance Draft iteration folded in without per-round rows. ADR-001 pins bumped v.3 → v.4. |
| v.1 | 2026-06-08 | Ruslan Gabitov | Initial Draft. Foundational extension skeleton for ADR-002 — 9 contracts in pkg/ + bundled defaults, functional-options assembly, EngineRuntime (public) / RuntimeEnvironment (internal) split (Thresher implements EngineRuntime; RuntimeEnvironment embeds it; Instance embeds it), startup log; executed extensions (ExpressionEngine/Clock/Logger) wired, the rest define-and-default only (N2); EventHub stays internal (not an extension point); RuntimeAware adapter-injection deferred (N4); human-interaction/TaskDistributor deferred to its own ADR (N6). Closes ADR-002 §7 defaults-only rows on landing. |