SRD-037 — Service Task outcome classification & output mapping (M4, M5)¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-07 |
| Owner | Ruslan Gabitov |
| Implements | ADR-021 v.1 Service Task Execution Model §2.5–§2.6 |
Accepted — third of four SRDs landing ADR-021 v.1 (M4 + M5 of M1–M8). Builds on the external-worker foundation (SRD-036, Accepted): a worker-dispatched ServiceTask parks, enqueues, and resumes on a
Complete/Failreport. M4 adds outcome classification — a worker outcome resolves to one of four kinds (success, Business Error → BPMN errorCode, Business Status → status variable, technical → terminal here) by code + body, via worker self-classification and a first-class declarativeErrorMapper, withWithStatus. M5 adds output mapping —WithOutputMappingshapes a raw response body into theDataOutput. Out of scope, → SRD-038 (M6–M8): theWithWorkerTrustknob (policy-bundle shipping +EngineAuthoritative), the retry policy (technical faults stay terminal here; SRD-038 re-routes them through retry), and the worked example. Sibling: SRD-035 (M1, Accepted), SRD-036 (M2/M3, Accepted).
1. Background (verified against the code)¶
1.1 The decision this SRD lands (ADR-021 v.1 §2.5–§2.6)¶
A worker outcome is not a bare Complete/Fail. It resolves to four kinds (§2.6): Success (bind the
output, complete); Business Error — an interrupting, model-relevant failure raised as a BPMN Error
(errorCode) that an Error boundary event catches (ADR-018); Business Status — a non-interrupting
outcome written to a status variable so the task completes normally and a downstream gateway branches (the
Camunda-Connector idiom); technical fault — a transient infrastructure failure. Classification is decided
when the report arrives (no goroutine) by the outcome's code (a protocol/domain status) and body (the
payload): a worker may self-classify, or report a raw fault the engine classifies via a declarative
ErrorMapper (match(code, bodyClause?) → BpmnError | Status | Technical, first match wins). Output mapping
(§2.5) shapes a raw response body into the DataOutput via WithOutputMapping — the success-path twin of
the ErrorMapper, both reading the body through the same protocol-bound path mechanism.
1.2 The base this SRD extends — SRD-036 M2/M3 (verified, Accepted)¶
The worker report surface today is two terminal kinds (pkg/tasks/workerdispatcher.go:108-118):
Complete(ctx, jobID JobID, workerID WorkerID, output *data.ItemDefinition) error
Fail(ctx, jobID JobID, workerID WorkerID, cause error) error // ← reworked in M4 (structured fault)
// "Report methods for the classified outcomes (business status, BPMN error) join in SRD-037." (:82-83)
The report rides back as a tasks.WorkerOutcome (pkg/tasks/workeroutcome.go:21-45: {cause, output, jobID},
NewWorkerComplete / NewWorkerFail), a synthetic flow.EventDefinition delivered to the parked track;
ServiceTask.ProcessEvent (pkg/model/activities/service_task.go:356-378) type-asserts it and stashes
completedOutput / outcomeErr; execWorkerOutcome (:305-332) binds the output (re.Put) or returns the
fault on resume. M4/M5 extend exactly these three seams — the report surface, the WorkerOutcome, and
ProcessEvent/execWorkerOutcome.
1.3 The machinery M4/M5 reuse (verified — all present, nothing new at the foundation)¶
- Raise a BPMN Error caught by errorCode.
events.BpmnError{Code string, Err error}+events.NewBpmnError(code, cause)(pkg/model/events/bpmn_error.go:15-33). The loop'smatchErrorBoundary(internal/instance/boundary_watch.go:186-224) doeserrors.As(t.lastErr, &be)(:194) and matchesbe.Codeagainst each Error boundary'sErrorEventDefinition.Error().ErrorCode()(:210-213), spawning the boundary's exception flow on a match (:215-217); no match → the instance faults (applyFailed,instance.go:1138). So a Business Error is raised by returning a*events.BpmnErrorfromexecWorkerOutcome— the track fails with it aslastErr, and the existing SRD-029 path catches it. No new raise mechanism. - Write a named scope variable.
renv.RuntimeEnvironment.Put(dd ...data.Data)(pkg/renv/runtimeenvironment.go:39) stores node-produced values that reach the container scope at frame commit;data.MustParameter(name, data.MustItemAwareElement(item, data.ReadyDataState))(service_task.go:317-319) is the exact shapeexecWorkerOutcomealready uses to commit its output. SoWithStatuswrites a Parameter named by the option — a downstream exclusive gateway reads it. - Evaluate an expression over data (predicates + output mapping).
expression.Engine.Evaluate(ctx, expr data.FormalExpression, src data.Source) (data.Value, error)(pkg/model/expression/expression.go:21-24), Go-native defaultgoexpr(goexpr/goexpr.go:20-24); the exclusive gateway evaluates its conditions this way —re.ExpressionEngine().Evaluate(ctx, cond, re)(pkg/model/gateways/gateway.go:236). So the in-processErrorMapperbody-clause andWithOutputMappingreusere.ExpressionEngine()over a Source exposing the outcome's{code, body}. - Operation error classes.
service.Operation.Errors() []string(operation.go:52, fromImplementor.ErrorClasses(),:114-116) — string classes, no expliciterrorReffield. The ADR's "the mapped errorCode should correspond to a declarederrorRef" is thus an advisory convention (name ↔ code), not an enforced link.
1.4 Scope boundary — trusted-implicit; trust knob & retry → SRD-038 (design choice, confirmed)¶
0.1.x has only in-process local Go workers via the localdispatcher pool (SRD-036) — no remote or untrusted
worker until ADR-004. Every worker is therefore effectively trusted, so M4/M5 land the outcome model +
engine-side application on the WorkerTrusted-implicit path (worker self-classification honored; ErrorMapper
as the fallback over a raw fault), without the explicit WithWorkerTrust(mode) knob. The knob (policy-bundle
shipping + the EngineAuthoritative sole-authority path) and the retry policy move to SRD-038 — where the
two trust modes' only observable difference (retry location) becomes real. A technical fault is terminal
here (Failing → Failed), exactly as Fail is in M3; SRD-038 re-routes it through retry — a change to how the
outcome is handled, forward-compatible (no signature change on the report call).
2. Requirements¶
Functional — M4 (classification)¶
- FR-1 — Structured fault
{code, body}.Failis reworked to carry a structured fault, not a bare Goerror:Fault{ Code string; Body *data.ItemDefinition; Cause error }(Causeis the diagnostic Go error).WorkerDispatcher.Fail(ctx, jobID, workerID, Fault); theWorkerOutcomecarries the raw fault so the engineErrorMappercan classify{code, body}at resume. A pure-technical fault isFault{Cause: err}(empty code, nil body) — no rule matches → default technical (§2.6). - FR-2 — Worker self-classified reports. Two report methods join
WorkerDispatcher(SRD-036 forward note):ReportBpmnError(ctx, jobID, workerID, code string, message string)(the worker declares a Business Error = CamundahandleBpmnError) andReportStatus(ctx, jobID, workerID, value *data.ItemDefinition)(the worker declares a Business Status). Each becomes a classifiedWorkerOutcomevariant. Explicit worker classification wins over theErrorMapper(precedence §2.6). - FR-3 — Declarative
ErrorMapper(engine-side). AnErrorMapperis an ordered rule list,match(codeMatcher, bodyClause?) → BpmnError{code, message?} | Status{value} | Technical(first match wins);bodyClauseis an optionaldata.FormalExpressionevaluated over the fault's{code, body}(in-process binding: gobpm expressions over Go values),valuea literal or body-extracted. A pluggableErrorMapperinterface covers imperative cases the rule list can't express. Configured two-level:WithWorkerErrorMapper(m)(engine-wide default) andWithErrorMapper(m)(per-service override). The engine applies it to a rawFailonly (an explicitReportBpmnError/ReportStatusbypasses it); no rule matches → default technical. - FR-4 — Business Error application. A
BpmnError{code, message?}outcome (worker-reported or mapper-yielded) makesexecWorkerOutcomereturnevents.NewBpmnError(code, message-as-cause)as the resume error; the track fails with it and the existingmatchErrorBoundarypath (§1.3) routes it to a matching Error boundary event (interrupting,Failing → Failed→ exception flow) or, unmatched, faults the instance. The mappederrorCodeshould correspond to one of theOperation's declared error classes (advisory, §1.3). Never retried. - FR-5 — Business Status application +
WithStatus. AStatus{value}outcome writesvalueinto a task-scoped variable named byWithStatus(statusName string, overwrite bool)(viare.Put(data.MustParameter(statusName, …))) and the task completes normally (tokens on the outgoing flows).overwrite = false(default): a pre-existingstatusNamein scope is a runtime fault (no silent clobber);overwrite = true: upsert. AStatusoutcome (worker-reported or anErrorMapperrule yieldingStatus) on a ServiceTask with noWithStatusis a build-time error where statically detectable (a mapper rule) and a runtime fault otherwise (a worker report). Never retried. - FR-6 — Technical fault is terminal (retry → SRD-038). A
Technicaloutcome (rawFailunmatched by the mapper, ordefault technical) faults the taskFailing → Failedwith the wrappedCause— exactly as M3'sFail. Retry (re-enqueue / worker-internal) is out of scope (SRD-038); the outcome handling is forward-compatible (SRD-038 re-routes it, no report-surface change).
Functional — M5 (output mapping)¶
- FR-7 —
WithOutputMapping. An optionalWithOutputMapping(rules)declares{ body-path → output variable }rules that extract fields from aComplete's raw response body into theOperation'soutMessage/DataOutput, viadata.FormalExpressionover the body (in-process binding). Absent a mapping, theCompletepayload is taken as theoutMessagedirectly (the existing M3 path — it must match the shape). A required output path the response does not satisfy is a technical fault (the worker's response violated the contract, §2.6). This is the success-path twin of theErrorMapper(FR-3), sharing the path mechanism.
Non-functional¶
- NFR-1 (no goroutine). Classification + mapping run at resume in
execWorkerOutcome(on the track's resume, triggered by the report) usingre's expression engine + scope — synchronous, no held goroutine (§2.6 "evaluated when the report arrives — no goroutine"). - NFR-2 (single-writer preserved). The status write is a
re.Putinto the resume frame (committed on the single-writer path); the Business Error reuses the loop-ownedmatchErrorBoundary(ADR-017/SRD-029). The dispatcher only reports; it never classifies against instance scope. - NFR-3 (protocol-bound mapper seam). The mapper abstraction (rule =
code+ a predicate overbody) is fixed and shared byErrorMapperandWithOutputMapping; the concrete binding is a seam — in-process = gobpmFormalExpressionover Go values (this SRD); JSON body + JSONPath + HTTP-status → ADR-004. An embedder can supply a binding for any protocol. - NFR-4 (trusted-implicit, forward-compatible). No
WithWorkerTrustknob (SRD-038); worker self-classification is honored and theErrorMapperis the fallback — theWorkerTrustedprecedence (§2.6). SRD-038 adds the knob EngineAuthoritative(ignore worker self-classification) + policy shipping without changing these outcomes.- NFR-5 (gate). diff-coverage ≥95% on touched files;
make cigreen; the classification/mapping + the Business-Error boundary path are-raceclean.
3. Models¶
3.1 Report surface — pkg/tasks/workerdispatcher.go (EXTEND)¶
// Fault is a worker's raw (unclassified) terminal fault. Code is a protocol/domain
// status (e.g. an HTTP status once a remote transport exists, ADR-004); Body is the
// response payload; Cause is the diagnostic Go error. The engine ErrorMapper
// classifies {Code, Body}; an all-empty Fault (just Cause) → default technical.
type Fault struct {
Body *data.ItemDefinition
Cause error
Code string
}
type WorkerDispatcher interface {
// ... Enqueue / FetchAndLock / ExtendLock unchanged (SRD-036) ...
// Complete reports success (unchanged).
Complete(ctx context.Context, jobID JobID, workerID WorkerID, output *data.ItemDefinition) error
// ReportBpmnError declares a Business Error (Camunda handleBpmnError): the engine
// raises errorCode, caught by a matching Error boundary event.
ReportBpmnError(ctx context.Context, jobID JobID, workerID WorkerID, code, message string) error
// ReportStatus declares a Business Status: the engine writes value to the
// WithStatus variable and the task completes normally.
ReportStatus(ctx context.Context, jobID JobID, workerID WorkerID, value *data.ItemDefinition) error
// Fail reports a raw fault; the engine ErrorMapper classifies it (REWORKED: Fault
// replaces the bare cause error).
Fail(ctx context.Context, jobID JobID, workerID WorkerID, fault Fault) error
}
3.2 WorkerOutcome — the classified completion event (EXTEND)¶
// WorkerOutcome now carries one of four classifications for job JobID. kind selects
// which fields are meaningful; ServiceTask.Exec dispatches on it at resume.
type WorkerOutcome struct {
output *data.ItemDefinition // kindComplete
bpmnCode string // kindBpmnError
bpmnMsg string // kindBpmnError
status *data.ItemDefinition // kindStatus
fault Fault // kindFault (raw — engine classifies)
jobID JobID
kind outcomeKind
foundation.BaseElement
}
// Constructors: NewWorkerComplete(jobID, output) (existing), NewWorkerBpmnError(jobID,
// code, msg), NewWorkerStatus(jobID, value), NewWorkerFault(jobID, Fault) (replaces
// NewWorkerFail). Accessors expose kind + the active field.
3.3 The ErrorMapper rule model — pkg/tasks (NEW)¶
// Outcome kinds a rule yields.
type MappedOutcome interface{ mappedOutcome() }
type BpmnError struct{ Code, Message string } // -> Business Error (interrupting, ADR-018)
type Status struct{ Value data.Value } // -> Business Status (WithStatus var)
type Technical struct{} // -> retry policy (SRD-038); terminal here
// Rule matches on code and, optionally, a predicate over the body; first match wins.
type Rule struct {
Code string // exact code match ("" = any)
BodyClause data.FormalExpression // optional predicate over {code, body}; nil = code-only
Yield MappedOutcome
}
// ErrorMapper classifies a raw fault. The declarative implementation is an ordered
// Rule list; a custom implementation covers imperative cases.
type ErrorMapper interface {
Classify(ctx context.Context, ee expression.Engine, f Fault) (MappedOutcome, error)
}
The declarative ErrorMapper evaluates each Rule.BodyClause (when present) with ee.Evaluate over a transient
data.Source exposing the fault's code and body, returning the first rule's Yield; none match →
Technical{}. The data.Source interface is just Find(ctx, name string) (Data, error)
(pkg/model/data/data.go:28-32), so the binding is a lightweight in-memory adapter whose Find resolves
"code" → a string datum and "body" → the fault body — the same shape a FormalExpression reads from re
(the gateway-condition precedent, gateway.go:236). (Validating this adapter's ergonomics is the one open
implementation detail M4 confirms first.)
3.4 ServiceTask options + config (activities, EXTEND)¶
srvTaskConfig (SRD-035/036) gains errorMapper tasks.ErrorMapper, statusVar string + statusOverwrite bool,
and outputMapping []tasks.OutputRule. New SrvTaskOptions (same closure pattern as WithTimeout/WithWorker,
[[feedback_option_constructors]] — self-naming, reject invalid input, never erase a default):
func WithErrorMapper(m tasks.ErrorMapper) SrvTaskOption // per-service; nil rejected
func WithStatus(statusName string, overwrite bool) SrvTaskOption // empty name rejected
func WithOutputMapping(rules ...tasks.OutputRule) SrvTaskOption
// engine-wide default: WithWorkerErrorMapper(m) on the thresher/enginert config (two-level, §2.2)
Build-time guards (in NewServiceTask, beside the M3 WithWorker+goOperation guard): a declarative ErrorMapper
rule that yields Status while WithStatus is unset → error; WithStatus/WithErrorMapper/WithOutputMapping
on a non-worker ServiceTask → error (they govern the worker outcome only).
3.5 Resume dispatch — ServiceTask.ProcessEvent / execWorkerOutcome (EXTEND)¶
ProcessEvent stashes the whole WorkerOutcome (its kind + active field). execWorkerOutcome dispatches:
flowchart TD
R[WorkerOutcome at resume] --> K{kind}
K -->|Complete| OM[apply WithOutputMapping over body -> outMessage / DataOutput; re.Put; Outgoing]
K -->|BpmnError| BE[build events.NewBpmnError code+msg; return the BpmnError as error; track fails; matchErrorBoundary catches]
K -->|Status| ST[if overwrite false read scope for name and fault on collision; re.Put status var; Outgoing]
K -->|Fault raw| EM[ErrorMapper.Classify code+body]
EM -->|BpmnError| BE
EM -->|Status| ST
EM -->|Technical / no match| TF[wrapped fault -> Failing to Failed retry in SRD-038]
3.6 Lifecycle (worker report → classified terminal)¶
sequenceDiagram
participant W as Worker (pull)
participant JQ as Job store (dispatcher)
participant IL as Instance loop
participant T as ServiceTask track (resume)
alt success
W->>JQ: Complete(jobID, output)
JQ->>IL: WorkerOutcome complete
IL->>T: resume -> Exec: WithOutputMapping -> DataOutput -> Completed
else worker self-classifies a business error
W->>JQ: ReportBpmnError(jobID, code, msg)
JQ->>IL: WorkerOutcome bpmnError
IL->>T: resume -> Exec returns BpmnError -> matchErrorBoundary -> exception flow
else worker self-classifies a business status
W->>JQ: ReportStatus(jobID, value)
JQ->>IL: WorkerOutcome status
IL->>T: resume -> Exec writes WithStatus var -> Completed -> gateway branches
else raw fault (engine classifies)
W->>JQ: Fail(jobID, Fault code body cause)
JQ->>IL: WorkerOutcome fault
IL->>T: resume -> Exec: ErrorMapper.Classify -> BpmnError / Status / Technical
end
4. Analysis¶
4.1 Classify at resume (execWorkerOutcome), not at report time (FR-3, FR-4, NFR-1)¶
The ErrorMapper needs both the ServiceTask's mapper config and an expression engine + data source; re
(the RuntimeEnvironment) carries both, and it exists only on the track's resume Exec. Running the mapper
there — the direct, synchronous consequence of the report arriving (report → ReportJobCompletion →
handleJobCompletion → deliver → resume → Exec) — satisfies the ADR's "evaluated when the report arrives — no
goroutine" while reusing the exact seam M3 already runs (execWorkerOutcome). Rejected: classify on the loop in
handleJobCompletion — the loop would have to open a frame and run expression evaluation per report (heavier,
and it duplicates the resume machinery); the raw {code, body} riding in the WorkerOutcome to the resume is
cheaper and keeps the loop a pure router (NFR-2).
4.2 Business Error reuses the existing raise→catch path (FR-4)¶
matchErrorBoundary already does errors.As(t.lastErr, &be) against *events.BpmnError (§1.3). So a Business
Error is raised by building events.NewBpmnError(code, message) and returning the resulting *events.BpmnError
as the resume error; the track fails with it as lastErr and the SRD-029 boundary path catches it by code.
errors.As traverses the chain (errs.ApplicationError.Unwrap links the cause, verified), so returning it raw
is simplest and an errs-wrapped one would also match. NewBpmnError returns (*BpmnError, error) and errors
only on an empty code — which a Business-Error outcome's non-empty code precludes; the construction error is
propagated defensively as a technical fault. Zero new raise mechanism. Rejected: a bespoke "throw error" call
from Exec — duplicates the fail→boundary path ADR-018/SRD-029 owns.
4.3 Structured Fault{code, body, cause} over a bare error (FR-1)¶
The declarative ErrorMapper (ADR §7 requires it functional in 0.1.x) needs a {code, body} to classify; a bare
Go error carries neither. Fault gives the in-process worker a structured raw fault the mapper reads, and is
the ready seam ADR-004 plugs HTTP {status, JSON} into. Cause is retained for the diagnostic (a
pure-technical fault is Fault{Cause: err} → default technical). Rejected: keep Fail(cause error) + a separate
raw-fault method — two fault-report methods for one concept; a single structured Fault is cleaner and matches
the ADR's "a fault is a structured outcome carrying a code and a body".
4.4 WithStatus writes a free-named scope variable (FR-5)¶
re.Put(data.MustParameter(statusName, …)) writes a named Parameter into the resume frame → the container
scope at commit (§1.3), exactly the shape a downstream exclusive gateway reads via re.ExpressionEngine(). The
overwrite=false collision guard reads scope for statusName before writing and faults on a hit — no silent
clobber (§2.6). Rejected: require a declared DataOutput for the status — the status variable is an engine
addition orthogonal to the Operation's outMessage contract; a free-named var matches the Camunda-Connector
"response-mapping → variable → gateway" idiom the ADR cites.
4.5 Mapper binding is a protocol seam, shared by both mappers (FR-3, FR-7, NFR-3)¶
ErrorMapper (classify a fault) and WithOutputMapping (shape an output) read the body through the same path
mechanism — a (body format, path language) binding. In-process that is gobpm FormalExpression over Go values
(this SRD); JSONPath-over-JSON rides with the HTTP transport in ADR-004. Fixing the abstraction now and leaving
the binding pluggable means ADR-004 adds a binding, not a redesign. Rejected: a Go predicate func(code, body)
bool for the in-process body-clause — it bypasses the expression mechanism the ADR names and diverges from the
WithOutputMapping path; the pluggable ErrorMapper interface already covers imperative cases.
4.6 Trusted-implicit; trust knob + retry deferred (NFR-4, FR-6)¶
See §1.4. 0.1.x workers are trusted local Go workers, so worker self-classification is honored and the
ErrorMapper is the fallback (WorkerTrusted precedence) — the knob would be inert. Deferring WithWorkerTrust
and retry to SRD-038 keeps this SRD to the outcome model and its application; SRD-038 adds where the
policy runs (trust) and what a technical outcome triggers (retry) together, since that is their shared concern.
5. API / contract surface¶
- Reworked:
WorkerDispatcher.Fail(cause error→Fault);WorkerOutcome(classified variants,NewWorkerFail→NewWorkerFault);localdispatcher+ the generatedWorkerDispatchermock update. - New (
pkg/tasks):Fault,ReportBpmnError/ReportStatus(interface methods),ErrorMapper+Rule/MappedOutcome/BpmnError/Status/Technical,OutputRule,NewWorkerBpmnError/NewWorkerStatus. - New (
activities):WithErrorMapper,WithStatus,WithOutputMappingSrvTaskOptions +srvTaskConfigfields + build guards;execWorkerOutcome/ProcessEventclassification dispatch. - New (engine config):
WithWorkerErrorMapper(engine-wide default) on the thresher/enginert config +renv.EngineRuntimeaccessor (two-level, §2.2).
6. Test scenarios¶
| Test | FR/NFR | Scenario |
|---|---|---|
TestWorkerReportBpmnErrorRaisesBoundary |
FR-2, FR-4 | ReportBpmnError(code) on a ServiceTask with a matching Error boundary → interrupts, resumes the exception flow |
TestWorkerBpmnErrorUnmatchedFaultsInstance |
FR-4 | a Business Error with no matching boundary → instance faults (not silently completed) |
TestWorkerReportStatusWritesVarAndCompletes |
FR-2, FR-5 | ReportStatus(value) writes the WithStatus variable and the task completes; a downstream gateway reads it |
TestWithStatusOverwriteGuard |
FR-5 | overwrite=false + pre-existing var → runtime fault; overwrite=true → upsert |
TestStatusWithoutWithStatusIsError |
FR-5 | a mapper rule yielding Status with no WithStatus → build error; a worker ReportStatus with none → runtime fault |
TestErrorMapperCodeOnlyToBpmnError |
FR-3, FR-4 | a raw Fail{code:"409"}, mapper rule code 409 → BpmnError → boundary |
TestErrorMapperBodyClauseToStatus |
FR-3, FR-5 | Fail{code:"404", body} + bodyClause $.type=="NOT_FOUND" → Status written |
TestErrorMapperNoMatchIsTechnical |
FR-3, FR-6 | Fail{code:"500"} unmatched → default technical → Failing → Failed |
TestWorkerClassificationBeatsMapper |
FR-2, FR-3 | an explicit ReportBpmnError is honored even when a mapper rule would classify differently (precedence) |
TestTwoLevelErrorMapperOverride |
FR-3 | per-service WithErrorMapper overrides the engine-wide WithWorkerErrorMapper |
TestWithOutputMappingShapesBody |
FR-7 | Complete with a raw body + WithOutputMapping {$.data.id → orderId} → orderId in DataOutput |
TestOutputMappingDirectReconciliationDefault |
FR-7 | no WithOutputMapping → the Complete payload is the outMessage directly (M3 path) |
TestRequiredOutputPathUnsatisfiedFaults |
FR-7 | a required output path the body lacks → technical fault |
TestClassificationOptionsRejectNonWorker |
FR-5, §3.4 | WithStatus/WithErrorMapper/WithOutputMapping on a non-worker ServiceTask → build error |
7. Milestones¶
- M4 — structured
Fault+ classified report surface (ReportBpmnError/ReportStatus,Fail(Fault)) +WorkerOutcomevariants + theErrorMapperrule model +WithStatus/WithErrorMapper/WithWorkerErrorMapper execWorkerOutcome/ProcessEventclassification dispatch (Business Error → boundary, Business Status → var, technical → terminal) + build guards.localdispatcher+ mock update. One commit.- M5 —
WithOutputMapping+ the shared body-path binding (in-processFormalExpression) applied on aComplete; direct-reconciliation default; required-path fault. One commit.
8. Cross-doc¶
- Implements: ADR-021 v.1 §2.5 (output mapping), §2.6 (outcome classification).
- References (up / sideways): ADR-018 v.1 (Business Error → Error boundary interruption + chain — the FR-4 application), ADR-011 v.5 (operation / data binding, expression mechanism), ADR-017 v.1 §2 (loop delivery / single-writer), ADR-001 v.6 (execution model), SAD-001 v.1 §11, §13.2.
- Sibling SRDs: SRD-035 (M1, Accepted), SRD-036 (M2/M3, Accepted); SRD-038 (M6–M8 —
WithWorkerTrust, retry, example) forthcoming. SRD→SRD sideways; pins by number. - Backlog: AB-005 (structured
ItemDefinitioncompose/spread) is the data-layer follow-up a richWithOutputMapping(multi-variable spread) will lean on; out of scope here. - Direction: SRD → ADR / SAD (up), SRD → SRD (sideways); no downward reference. ADR-021 stays Draft until SRD-038 lands.
9. Definition of Done¶
- FR-1…FR-7 implemented and wired; NFR-1…NFR-5 upheld.
- Every FR/NFR covered by ≥1 named §6 test, all green under
-race(per-package coverage for theactivities/service/tasksworker methods — the [[SRD-036]] per-package-gate lesson). - The
Fail(cause error)surface is reworked toFail(Fault)with no stale callers;WorkerOutcomevariants wired; the generated mock regenerated. make cigreen (tidy · lint · build ·-race· diff-coverage ≥95% on touched files · govulncheck).- SRD-037 flips to Accepted. ADR-021 stays Draft until SRD-038 is grounded.
10. Implementation summary (stage-by-stage actual landings + deltas vs draft)¶
10.1 Stage commits (branch feat/service-task-classification)¶
| Stage | Commit | Scope | Key tests |
|---|---|---|---|
| M4 | 3972fd5 |
classification model (Fault, sealed MappedOutcome, RuleMapper + faultSource, report surface Fail(Fault)/ReportBpmnError/ReportStatus, WorkerOutcome variants) + execWorkerOutcome dispatch (raiseBpmnError/writeStatus/classifyFault/technicalFault) + WithErrorMapper/WithStatus + build guards + the observability overhaul (§10.3) |
TestRuleMapper* (7), TestServiceTaskWorker{ExecBindsCompletedOutput, ExecFaultsOnCause, ExecRaisesBpmnError, ExecWritesStatus, StatusOverwriteCollision/True, StatusWithoutWithStatusFaults, FaultClassifiedByMapper, FaultMappedToStatus, FaultMapperError, BpmnErrorEmptyCodeFaults}, TestWorker{ReportBpmnErrorRaisesBoundary, BpmnErrorUnmatchedFaultsInstance, ReportStatusCompletes}, TestClassificationOptionsRejectNonWorker, TestLocalDispatcher{ReportBpmnError, ReportStatus, BindLogger} |
| M5 | a14500b |
OutputRule + ApplyOutputMapping (pkg/tasks/outputmapping.go) + WithOutputMapping + bindOutput applies it (direct-reconciliation default; required-path fault) |
TestApplyOutputMapping* (3), TestServiceTaskWorkerOutputMapping{ShapesBody, RequiredFaults}, TestWithOutputMappingRejects{InvalidRule, NonWorker} |
| gate | fbc2395 |
FR-3 two-level closure (§10.2-e): engine-wide WorkerErrorMapper default |
TestWorkerClassificationBeatsMapper, TestTwoLevelErrorMapperOverride, TestEngineDefaultErrorMapperUsed, TestWithWorkerErrorMapper* |
make ci green — lint/vet/build/-race clean, govulncheck clean, diff-coverage 98.1% of 365 changed lines (min 95%).
10.2 Empirical findings — where the implementation refined the §3/§4 draft¶
All preserve the required invariants; recorded here rather than by rewriting §3/§4 (one-shot doc).
- Classification runs at resume in
execWorkerOutcome(§4.1 confirmed). The mapper + status write + output mapping all run on the track's resumeExecwherere's expression engine + scope exist — the direct, synchronous consequence of the report (no held goroutine, NFR-1). The raw outcome rides in theWorkerOutcometo the resume; the loop stays a pure router. - Business Error reuses the existing raise→catch path (§4.2 confirmed).
raiseBpmnErrorbuildsevents.NewBpmnError(code, msg)and returns the raw*events.BpmnError; the loop'smatchErrorBoundarycatches it viaerrors.As(t.lastErr, &be)(boundary_watch.go) — zero new raise machinery.NewBpmnErrorerrors only on an empty code (a Business Error precludes it); that is propagated defensively as a technical fault. SrvTaskOptionbecame error-returning. To validate the new options (WithErrorMapperrejects nil,WithStatusrejects an empty name,WithOutputMappingrejects a nil Path / empty Var) the option type changed fromfunc(*srvTaskConfig)tofunc(*srvTaskConfig) error;WithTimeout/WithWorkerreturn nil. Public constructor signatures are unchanged.- Test renames (§6 → code). The
ErrorMapper*§6 names landed asRuleMapper*(the declarative impl);TestWithStatusOverwriteGuardsplit into collision + upsert tests;TestOutputMappingDirectReconciliationDefaultis covered byTestServiceTaskWorkerExecBindsCompletedOutput(no-mapping → direct bind); the worker-scoped tests carry aServiceTaskWorkerprefix. Intent preserved; §6 stays as authored, this table is the mapping. - FR-3 two-level engine-wide default landed at the landing gate (
fbc2395). M4 landed only the per-serviceWithErrorMapper;/check-srdflagged the engine-wideWithWorkerErrorMapper(FR-3 / §3.4 / §5) as missing. Closed by addingrenv.EngineRuntime.WorkerErrorMapper()(implemented byenginert+ the thresher config; set bythresher.WithWorkerErrorMapper/enginert.WithWorkerErrorMapper) and makingclassifyFaulttwo-level (per-service overrides engine-wide). The generatedRuntimeEnvironmentmock was regenerated.
10.3 Out-of-scope hardening folded into M4¶
- Observability overhaul of
localdispatcher(surfaced at implementation review): the worker pool'sComplete/Failreport errors are now logged, not dropped; logging is on by default (slog.Default()); full lifecycle logging was added (enqueue, fetch/lock, expired-lock reclaim, extend, the report funnel, worker registration); and the logger is taken from the runtime config via a newtasks.LoggerBinderseam bound inThresher.New(the dispatcher analogue ofSinkBinder).OutcomeKind.String()uses a constant-keyed array (project convention). - Coverage gate: the sealed
MappedOutcomemarker (func (X) mappedOutcome() {}) is structurally uncoverable (never called), so it was added to the MakefileCOVER_EXCLUDElist alongside the existingOption()marker exclusion.