SRD-050 — Call Activity: a child instance through the registry¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-16 (accepted 2026-07-17) |
| Owner | Ruslan Gabitov |
| Implements | ADR-023 v.1 §2.7 (the Call Activity slice — completes epic #85; the embedded slice landed with the prior SRD) |
| Upstream | ADR-019 v.1 (the registry the call resolves against: latest-at-launch / pinned versions), ADR-001 v.6 (the loop owns the call protocol), ADR-010 v.2 (the isolated child data plane; root binding), ADR-021 v.1 (the async park/resume + fault-classification idioms the caller reuses), ADR-013 v.2 (the linkage attributes) |
| Refines | — |
§1 Background¶
ADR-023 v.1 §2.7 decides the Call Activity as the reuse boundary: a child instance of a separately registered process — by reference, not containment — with latest-at-launch version binding (pinned as an option), an async caller park/resume, the standard's direct I/O mapping, an isolated child data plane, and a terminate cascade. The embedded slice landed everything scope-side; this slice adds the cross-instance protocol.
The code today:
- No invocation seam. An instance receives engine services via
engrenv.EngineRuntime(renv/engineruntime.go:23-58) and theTaskDistributor(instance.New,thresher.go:1126), but nothing lets it ask the engine to launch another process. The thresher owns the registry (registrations map[string][]*ProcessRegistration+nextVersion,thresher.go:153-156), the three launch modes (StartLatest/StartVersion/StartProcess,thresher.go:1036-1094),launchInstance(thresher.go:1125-1151) and the running-instance tracking (instances map[string]instanceReg). - The caller's park/resume template exists: the external-worker job —
the track parks, the loop binds input and enqueues, a report re-enters
through a dedicated channel and resumes the track via
evtCh(jobs.go;jobReq), withcleanupJobon track end. The embedded slice added the synthetic-completion resume (scopeDone) the call completion mirrors. - The child-root injection precedent exists:
instanceScope.bindEventPayloadcommits extra data into a fresh instance's root scope (internal/instance/scope.go:101-120) — the input-binding shape. - The model is a stub:
flow.CallActivityis a type constant;bpmncommon.CallableElementis name-only.activity.IoSpec+WithParameters(activity_options.go:163-191) already model named input/output parameters.
§2 Requirements¶
Functional — model layer¶
- FR-1 —
activities.CallActivity(newpkg/model/activities/call_activity.go): embeds theactivitybase;ActivityType() = flow.CallActivity; the concrete-typeNode()override (the SubProcess lesson — flow targets unwrap through it). Construction:NewCallActivity(name, calledKey string, opts...)— the registry key of the callable, non-empty (validated); optionWithCalledVersion(v int)pins an exact version (≥1), default 0 = latest-at-launch (ADR-019 semantics, ADR-023 §2.7). I/O parameters ride the existingactivities.WithParameters/data.InputOutputSpecificationsurface — the CallActivity's declared Inputs/Outputs ARE the call contract (§10.4: no explicit data associations). Accessors:CalledKey(),CalledVersion(). - FR-2 — Model validation.
CallActivity.Validate()(the per-node hook): a non-empty key; a pinned version ≥ 1 when set. Registry existence is deliberately NOT checked at model build — resolution is at call time (ADR-023 §2.7; the callable may be registered later or re-versioned). The runtime-facing surface:ProcessEventaccepts the completion delivery;Execruns the standard outgoing selection after the resume (the composite precedent).
Functional — the invoker seam¶
- FR-3 —
exec.ProcessInvoker. A new capability interface (inpkg/exec, beside the executor contracts):
// ProcessInvoker launches a registered process as a CHILD instance on
// behalf of a Call Activity (ADR-023 §2.7). Implemented by the engine
// (the thresher); consumed by the instance loop.
type ProcessInvoker interface {
// InvokeProcess resolves key (version 0 = latest-at-launch, else the
// pinned 1-based version), binds inputs into the child's root scope,
// launches it, and returns its watch handle. Resolution failures are
// classified errors (a missing key/version fails the CALL, not the
// engine).
InvokeProcess(ctx context.Context, call ProcessCall) (ChildProcess, error)
}
with ProcessCall{Key string; Version int; Inputs []data.Data;
ParentInstanceID, CallNodeID string} and ChildProcess exposing
ID() string, Version() int (the RESOLVED 1-based version bound — the
audit point FR-10 records for a latest-at-launch call), Done()
<-chan struct{}, Failed() error (the terminal fault, or nil on a
normal/cancelled end), Outputs(names []string) ([]data.Data, error)
(read the child's root data by name after completion) and Terminate().
Instance.New gains the invoker the way it carries the TaskDistributor
(nil = calls fail fast with a classified no-invoker-configured error — a
library embedder without a thresher). (M2 refinement: Version() was
added — the resolved version is otherwise unreachable through the
interface for a version-0 call, which FR-10 needs; Failed() dropped its
redundant bool — err != nil is the fault signal.)
- FR-4 — The thresher implements it. Thresher.InvokeProcess:
resolve per FR-3 through the existing lookup paths
(latestSnapshotLocked / the version scan — ADR-019 gap handling);
build the child through the launchInstance path with two additions:
the input binding — the call's Inputs committed into the child's
root scope at creation (the bindEventPayload pattern; an instance
option withRootData) — and the linkage details on the child's
facts (parent_instance_id, call_activity_node_id — new
observability.Attr* keys) plus a call-level fact pair from the caller
(FR-10). The child is tracked in t.instances as any instance
(shutdown semantics unchanged). ChildProcess.Outputs reads through
the child's observe reader by name.
Functional — runtime (the caller's loop)¶
- FR-5 — Classification & park.
checkNodeTyperecognizes the CallActivity between the composite and the external-worker checks (a capability assert —interface{ CalledKey() string }+flow.ActivityNode, keeping the runtime model-agnostic): the track parks onevtCh(TrackWaitForEvent) and emits the newevCallWaiting(mid-run; the born-parked twin ridesrecordBornWaiter, the SRD-048/-049 construction-never-emits rule). - FR-6 — The loop launches. On
evCallWaiting: resolve the call's inputs in the caller's scope — each declared Input parameter of the CallActivity's IoSpec is resolved by name at the caller track's scope path (a transient frame; §4.2 decides by-name) and cloned for the hand-off (the isolation contract: no live sharing across the call boundary);InvokeProcess; register the call in the loop-owned registrycalls map[callID]*callEntry{track, node, child}; start ONE watcher goroutine per call that waitschild.Done()and reports into the instance'scallReqchannel (thejobReqpattern — the loop stays the single writer). Launch/resolution failure → the call fails → the caller track faults with the classified error (the §2.6 chain at the CallActivity node — an Error boundary on it can catch a registry-resolution fault only if typed; a technical fault otherwise). - FR-7 — Completion & output binding. On the
callReqcompletion: childCompleted→ read the child's outputs by the CallActivity's declared Output parameter names (ChildProcess.Outputs), commit them into the CALLER's scope at the caller track's path (the loop-side commit; one atomic batch), then resume the parked track with the synthetic call-completion (thescopeDoneidiom — acallDonesentinel); the track executesCallActivity.Exec→ the standard outgoing selection. A missing declared output in the child's root is a classified error (the call contract broken → the caller faults through the §2.6 chain). - FR-8 — Child failure propagation. Child
Terminated/failed: if the child's terminal error is a typedBpmnError, the caller track fails WITH that error —matchErrorBoundary/the scope chain then catch it at the CallActivity node per the existingapplyFailedpath (ADR-023 §2.7: "a fault enters the caller's §2.6 chain at the Call Activity node"); an untyped termination is a technical fault (uncaught → the instance faults). - FR-9 — The cancel cascade. The caller's side ends the child
whenever the call's episode dies (ADR-023 §2.7 — the engine choice):
cleanupCall(track)hooks the same sites ascleanupJob— the terminal-event paths (evEnded/evFailed) andstopAll'sdrop— callingchild.Terminate()and dropping the entry. A call parked inside a cancelled sub-process scope needs no directcancelScopehook: the scope-cancel walk stops and cancels the host track, whose own terminalevEndedthen drainscleanupCallthrough the same path as any track end. The watcher goroutine's late report finds no entry and is dropped (benign). Instance shutdown needs nothing new — the child is a first-class instance under the thresher's own shutdown. - FR-10 — Observability. One new kind
KindCall("call-activity lifecycle") with phasesStarted/Completed/Failed/Terminated, emitted by the caller with detailscalled_key,called_version(the RESOLVED version — the latest-at-launch audit point ADR-023 §6 recommends), andchild_instance_id; the CHILD's own facts carryparent_instance_id+call_activity_node_id(FR-4), stitching the trace across the boundary.KindCallechoes at Info (a lifecycle milestone). A new kind — rather than overloadingKindJobState(a worker-queue vocabulary) orKindScope(a call is not a scope) — keeps each kind's phase set semantically closed.
Functional — front door¶
- FR-11 — thresher e2e (call completes with I/O round-trip; a pinned
version beats a later registration; child
BpmnErrorcaught by an Error boundary on the CallActivity; the cascade on caller termination); exampleexamples/call-activity/;docs/guides/composition.mdgains the Call Activity section; changelog; conformance tracker — theCallActivityrow flips and epic #85 closes; READMEs (EN/RU) + examples index. ADR-023 flips Accepted in the closing docs change-set (+ the RU twin — the bilingual policy).
Non-functional¶
- NFR-1 — Isolation is the contract. Nothing crosses the call boundary except the declared inputs (cloned in) and the declared outputs (read back at completion): no scope walk-up, no live value sharing, no event visibility. A called process runs identically however it is reached.
- NFR-2 — Single-writer preserved, both sides: the caller's call
registry is loop-owned (
callReqre-enters the loop, thejobReqtwin); the child is an ordinary instance with its own loop. The watcher goroutine only waits and reports. - NFR-3 — Call-free processes pay nothing: no calls registry traffic, no channel, no watcher goroutines unless a CallActivity executes.
- NFR-4 — Camunda-aligned defaults: latest-at-launch binding; the child terminates with the caller.
- NFR-5 — Coverage: touched files 100% (min 80%); diff-coverage ≥95%;
make ciper milestone (frozen-tree discipline).
§3 Models¶
§3.1 activities.CallActivity¶
// CallActivity invokes a separately registered process as a CHILD
// instance (ADR-023 §2.7): the reuse boundary. The caller parks while the
// child runs; the declared Input/Output parameters are the call contract
// (§10.4 direct mapping — no data associations).
type CallActivity struct {
activity
calledKey string
calledVersion int // 0 = latest-at-launch (ADR-019)
}
§3.2 exec.ProcessInvoker (FR-3, quoted there)¶
§3.3 Runtime deltas¶
trackEvent: kindevCallWaiting(+ names row).Instance: thecallReq chan callRequestchannel (thejobReqtwin; drained in the loop select) + the invoker reference. (M3 refinement: the invoker rides an exportedinstance.WithInvokerNew option, not a positionalNewparameter —Newhas ~113 call sites, and the option matches the existingwithBornEvent/withRootDatashape; the engine passesWithInvoker(t)at its three construction sites,NewFromEventgained an option tail. A nil invoker still fails the call fast.)loopState:calls map[string]*callEntry{track *track; node flow.Node; child exec.ChildProcess}keyed by the child instance id (the child handle already carries a unique id — no separate call id to mint);cleanupCallhooked besidecleanupJob, anddrop()terminates every in-flight child (a child runs under the engine's context, not the parent's, so a terminating parent does not auto-cancel it).- The completion/fault the loop delivers is
exec.CallOutcome(aflow.EventDefinitioncarrying an optionalerr):CallActivity.Execreturnserrwhen set (→ the track faults,matchErrorBoundarycatches at the node) else selects the outgoing flows. The delivery MUST ride the parked track'sevtCh— the loop cannot synthesize anevFailedfor a parked track (matchErrorBoundaryreadst.lastErr, set only by the track's ownrun()), so the fault flows through the node, theServiceTask/WorkerOutcomepattern. observability:KindCall+AttrParentInstanceID,AttrCallActivityNodeID,AttrCalledKey,AttrCalledVersion,AttrChildInstanceID; the echo table gainsKindCall: Info.
§4 Analysis¶
§4.1 Why the invoker is a first-class capability, not an engine-runtime method¶
engrenv.EngineRuntime is the stateless service surface every node
evaluation sees (clock, expressions, brokers). Launching a process is a
stateful engine operation with lifecycle consequences — exactly the
class the TaskDistributor models as a separate Instance.New
dependency. A distinct ProcessInvoker keeps the node-execution surface
closed (no node code can launch processes — only the loop's call
protocol), makes the library-embedder case explicit (nil invoker = calls
fail fast, classified), and gives tests a trivial fake.
§4.2 By-name I/O matching — an engine choice this SRD settles¶
ADR-023 §2.7 permits "positional/by-name direct binding" and left the mechanism here; this SRD narrows to by-name only (an explicit refinement, not a standard mandate — §10.4 prescribes only the absence of explicit associations). Matching is by parameter name: the CallActivity's declared Input names are resolved in the caller's scope and committed under the SAME names into the child's root; the declared Output names are read from the child's root and committed under the same names into the caller's scope. Rejected: positional matching — BPMN parameters are named elements, Go map iteration would make positions non-deterministic, and the reference engines (Camunda in/out mappings) are name-keyed. A name the caller cannot resolve at call time, or a declared output the child did not produce, is a classified contract error (fail fast — the silent-partial alternative is the misbehavior class the engine rejects).
§4.3 Why a watcher goroutine per call (and not hub events)¶
The child's completion is a cross-instance signal with exactly one
consumer. The hub distributes broadcast/correlated triggers; a
dedicated Done()-wait + callReq report (the jobReq shape) keeps
the protocol point-to-point, needs no correlation surface, and cannot
leak subscriptions — the watcher exits with the child, and a late report
against a cleaned-up call drops benignly (the job-report precedent).
§4.4 Recursion & the depth guard¶
A process may call itself (resolution is by key at call time — legal composition, ADR-023 §2.9). Unbounded recursion exhausts instances, not the stack; the operational depth guard recommended by ADR-023 §6 stays future work (an engine option counting the linkage chain) — recorded, not implemented here.
§4.5 Rejected shapes¶
- Blocking the caller's goroutine on
WaitCompletion: wastes a goroutine per call AND breaks the cooperative-cancellation model (the park must stay interruptible by boundaries/terminate through the loop). The park/report/resume shape is the engine's established async idiom. - Graph inlining / shared data plane: rejected in ADR-023 §4 (the reuse contract).
- Registry-existence validation at model build: would freeze the binding earlier than ADR-019's launch-time semantics and break the register-later workflow.
§5 API surface¶
Public: activities.NewCallActivity/CallActivity (+
WithCalledVersion), exec.ProcessInvoker/ProcessCall/ChildProcess,
the observability kind/attr additions. The thresher's InvokeProcess
is its ProcessInvoker compliance (not a new user-facing API — users
keep StartLatest/StartProcess). Everything else is internal/instance
machinery.
§6 Test scenarios¶
Model (M1): TestCallActivityModel (construction; empty key rejected;
version pin validated; Node() identity; the IoSpec surface),
TestCallActivityValidate.
Thresher/invoker (M2): TestInvokeProcessLatestAndPinned (latest
resolves the newest version; a pin survives a later registration; a
missing key/version is a classified error), TestInvokeProcessInputs
(inputs land in the child's root; the child reads them),
TestChildOutputsReader, TestChildLinkageFacts (the child's facts
carry the parent attrs).
Runtime (M3, internal/instance, a fake invoker): the park + launch
(TestCallParksAndLaunches), input cloning (TestCallInputsClonedToChild),
completion + output binding (TestCallCompletionBindsOutputs), the
missing-output contract error (TestCallMissingOutputFaults), child
BpmnError → the Error boundary on the CallActivity catches
(TestCallChildErrorCaught), untyped child termination → instance fault
(TestCallUntypedTerminationFaults), the cascade
(TestCallCascadeOnInstanceCancel — the instance-cancel path;
TestCleanupCallTerminatesOwnedChild the per-track cleanupCall; drop()
covers the stopAll teardown), nil-invoker fail-fast
(TestCallNoInvokerFailsFast), the launch-failure and missing-input faults
(TestCallInvokerErrorFaults, TestCallMissingInputFaults), the loop-side
guards (TestOnCallWaitingGuards), the boundary-crossing clone errors
(TestCloneNamedErrors, TestCallOutputCloneFaults), and the late-report
drop (TestCallLateReportDropped).
E2E (M4, pkg/thresher): TestCallActivityE2E — a registered callee +
a caller with I/O round-trip; the pinned-version case; the child-error
catch; the example smoke.
§7 Milestones¶
| # | Scope |
|---|---|
| M1 | The CallActivity model + validation + runtime surface (FR-1/2) + tests |
| M2 | exec.ProcessInvoker + the thresher implementation (resolution, input binding via the root-data option, linkage facts, ChildProcess) (FR-3/4) + tests |
| M3 | The caller's loop protocol: classification/park, evCallWaiting, launch, callReq resume with output binding, failure propagation, the cascade, KindCall (FR-5..10) + tests |
| M4 | Thresher e2e + examples/call-activity/ + guide section + changelog + tracker (#85 closes) + READMEs (FR-11) |
Post-M4: /check-srd, §10 fill, SRD → Accepted, ADR-023 → Accepted +
the RU twin refresh, linked-docs sync (the ADR-018 boundary-on-
CallActivity deferral row gains its landed-elsewhere annotation only if
boundary-on-CallActivity actually lands here — it does: a CallActivity is
an activity, the boundary machinery consumes the base unchanged), PR
handover closing #85.
§8 Cross-doc¶
- Implements ADR-023 v.1 §2.7 (traced per FR).
- Rides ADR-019 v.1
resolution; extends ADR-001 v.6
loop protocol; keeps ADR-010 v.2
isolation; reuses ADR-021 v.1
park/report idioms; extends
ADR-013 v.2 with
KindCall+ the linkage attributes.
§9 Definition of Done¶
- [x] All FR/NFR wired and traced to §6 tests.
- [x]
make cigreen per milestone (frozen tree); diff-coverage ≥95% (96.1% of 485 lines at M4); touched files 100% (min 80%). - [x] Example runs to completion (exit 0), binary gitignored.
- [x] Conformance tracker: the CallActivity row flips; #85 closes.
- [x] Changelog
[Unreleased]before the PR description. - [x]
/check-srdPASS; §10 filled; SRD Accepted; ADR-023 Accepted + RU twin; linked docs synced.
§10 Implementation summary¶
Landed on feat/call-activity (rebased onto master dd487d9), five commits:
| Stage | Commit | Scope |
|---|---|---|
| Doc | 9550a23 |
this SRD (371 lines) |
| M1 | 0170d43 |
activities.CallActivity model node + validation + runtime surface (FR-1/2) + tests (3 files) |
| M2 | 8dca409 |
exec.ProcessInvoker/ProcessCall/ChildProcess seam; Thresher.InvokeProcess + resolveCallLocked + childProcess; instance.NewChild + withRootData/withCallLinkage/WithInvoker; the child-fact linkage stamp (FR-3/4) + tests (11 files) |
| M3 | d3d4786 |
the caller loop protocol — calls.go (onCallWaiting/handleCallCompletion/cleanupCall/watchCall/reportCall), evCallWaiting, callReq + calls map, exec.CallOutcome, checkNodeType classification, KindCall (FR-5..10) + tests (18 files) |
| M4 | 2e6d797 |
thresher e2e + examples/call-activity/ + composition guide + changelog + tracker (#85 closes) + READMEs (FR-11) (14 files) |
Verification. make ci green at M4 — diff-coverage 96.1 % of 485
changed lines (≥95 %), lint 0, -race, govulncheck clean; the example
runs to completion (exit 0), binary gitignored.
§10.1 Empirical findings — where reality diverged from the draft¶
- Invoker threading = an option, not a positional
Newparam (§3.3 refinement).instance.Newhas ~113 call sites; a positional invoker param was untenable, so the engine passes an exportedinstance.WithInvokerNew option (thewithBornEvent/withRootDatashape).NewFromEventgained an option tail; the thresher passesWithInvoker(t)at its three construction sites. - The fault MUST ride
evtChthrough the node. The loop cannot synthesize anevFailedfor a parked track —matchErrorBoundaryreadst.lastErr, set only by the track's ownrun(). So a child fault is delivered as anexec.CallOutcomethatCallActivity.Execreturns (theServiceTask/WorkerOutcomepattern), which produces theevFailedthe boundary matcher catches at the node (FR-8). ChildProcess.Version()was added (FR-3 refinement). FR-10 records the resolved version; for a latest-at-launch (version-0) call that number is otherwise unreachable through the interface.Failed()dropped its redundantbool(a non-nil error is the fault signal).drop()terminates in-flight children explicitly (FR-9). A child runs under the engine's context, not the parent's, so a terminating parent does not auto-cancel it; instance teardown iteratescallsand callschild.Terminate()before clearing the registry.- Child output path. A ServiceTask's returned
ItemDefinition(with an explicit id) commits to the child root by that id, soChildProcess.Outputsreads it by name — the e2e round-trip (subtotal→ child →total) confirms the full data path.
Open questions¶
None.