Release v0.0.80
Overviewβ
This release makes a client request observable from end to end and finishes the Busy-as-mutex work started in v0.0.79. Armed timers are now visible to clients β the state response carries a scheduledTransitions list built from active scheduled-transition jobs, each with a persisted UTC executeAtUtc taken from the new InstanceJob.ExecuteAt column, and the scheduled job set folds into the fingerprint ETag so a parked long-poller no longer misses a re-armed timer (response shape v6) (#876). Correlation becomes a four-carrier contract β W3C trace context, X-Request-Id, X-Correlation-Id and X-Workflow-Instance-Id each do exactly one job, propagate across the orchestration β execution β outbound-task boundary, and land in Elastic under stable label names, so one query returns a whole flow (#879, #882). Busy-as-mutex is refined in three places: a $self transition no longer re-runs the state's lifecycle, the accept path takes one lock instead of two, and an async accept on a parent holding an open SubFlow correlation now reserves the whole chain down to the leaf before answering 202 (#883, #884). This release runs on component schema 0.0.52.
Featuresβ
Scheduled transitions in the state response (#876)β
An instance parked in a state with armed timers gave clients no way to learn when those timers would fire. The state (long-poll) response now carries a scheduledTransitions array:
{
"scheduledTransitions": [
{ "name": "escalate-to-supervisor", "kind": "scheduled", "executeAtUtc": "2026-08-17T14:32:05Z" }
]
}
- Entries are populated from active scheduled-transition jobs and ordered by execution time.
executeAtUtcis the exact UTC instant the scheduler was armed for. It is persisted on a newInstanceJob.ExecuteAtcolumn (migrationAddInstanceJobExecuteAt) rather than recomputed at read time, so the value a client sees is the value the scheduler holds.TimerSchedule.ResolveExecuteAtcomputes it deterministically from a single clock reference and is reused by both the workflow-timeout and state-transition scheduling paths.- A scheduled entry is not an invitation to call anything β execution stays System-actor gated.
InstanceStateFingerprintand its projection now aggregate over active scheduled-transition jobs (count plus newestCreatedAt), and those aggregates go into both the ETag material and the state-function cache key. Arming, cancelling or re-arming a timer therefore moves the ETag instead of leaving a long-poller on a stale304.- The state-function cache response shape version advances to v6, so cached bodies from the previous shape are not served.
Reference: PR #876 β see also Built-in Functions β State and Event-driven workflows.
End-to-end correlation and X-Request-Id tracing (#879, #882)β
Two problems were solved with one contract. Async processing runs every transition as a Dapr job, and a job callback is a fresh HTTP request β so an APM trace fell apart at that hop and orchestration, the job, execution and the remote task each became their own tree. Separately, function calls made before an instance exists (a start request) could not be queried at all, because there was no instance id yet to query by.
After this change one client request is one trace tree, and every log line in Orchestration, Execution, Inbox and Outbox carries x_request_id. Four carriers, four jobs β keeping them apart is the point:
| Carrier | Wire | Field | Scope |
|---|---|---|---|
| W3C trace context | traceparent / tracestate | β | The trace tree |
| Request id | X-Request-Id | x_request_id | One client request, across all services |
| Business correlation | X-Correlation-Id | correlation.id | Stable for a whole workflow chain |
| Instance id | X-Workflow-Instance-Id | workflow.instance.id | The instance, vendor-neutral |
Mechanics worth knowing:
- Immediate jobs continue the trace.
BackgroundJobActivityHelper.StartActivityContinuingTrace()makes the payload'sTraceParentthe span's real parent forflow.transitionandstate.notify, and attaches the Dapr callback span as anActivityLink(vnext.dapr.callback). Deferred jobs β timers, workflow timeout, long-poll ack β deliberately keep the old new-trace-plus-link shape: they fire minutes to days later and must not resurrect an expired trace. - Events restore the publisher's context. A new
EventTraceScopeon the Inbox side rebuilds the publishing context across the outbox β pub/sub β inbox hop, driven byITraceableDistributedEvent, whichHookedDistributedEventBusstamps centrally at publish time. - Pipeline step spans are created only in Verbose. The new
PipelineStepActivityHelperestablishes the rule: a span that Business mode would drop must never be created in the first place. Previously they were created always and dropped at export, which orphaned their children and pushedTaskCoordinator.Executeto the trace root. - Reserved headers.
traceparent,tracestate,baggage,x-request-id,X-Correlation-IdandX-Workflow-Instance-Idcannot be set by a task binding (IsReservedTraceHeader) β a stale traceparent or a forged correlation copied into a definition would detach or spoof the chain. Workflow context is removed and re-stamped from trusted baggage byInvokerHelpers.ApplyTrustedCorrelationHeaders(), applied by every HTTP-shaped invoker (http,soap,daprservice,daprhttpendpoint,trigger). Identity claimssub/act_subare fill-if-absent and deliberately not reserved: they are token-derived, but a binding may legitimately set them and that value wins. - Identity is normalized, not forwarded wholesale. Only
subandact.subpropagate; the completeactclaim is never propagated or logged. Values are accepted only if they matchA-Z a-z 0-9 _ -, are at most 128 characters, and come from the vNext trace context rather than a task mapping header. - Correlation fallback is the current W3C trace id when correlation baggage is unavailable, with an opaque generated GUID as the last resort. Instance ids are emitted as canonical lowercase UUIDs; correlation ids in the non-zero 32-character
Nformat.
In Elastic the attributes land as:
| OpenTelemetry attribute | Elastic field |
|---|---|
workflow.instance.id | labels.workflow_instance_id |
correlation.id | labels.correlation_id |
sub | labels.sub |
act.sub | labels.act_sub |
The W3C trace identifier stays available as the native trace.id. Useful Kibana Discover queries:
labels.workflow_instance_id : "<workflow-instance-id>"
labels.correlation_id : "<correlation-id>"
and service.name : ("vnext-app-<domain>" or "vnext-execution-app-<domain>")
trace.id : "<trace-id>"
Two limits are deliberate: system-triggered jobs (timer transitions, workflow timeout, long-poll ack) are separate traces by design and carry no request headers β correlate them by vnext.instance.id / correlation.id or follow the ActivityLink; and the Outbox publish loop runs in a BackgroundService with no request context, so its own publish lines carry no request id, though the id still travels inside the event and reappears on the Inbox side.
Reference: PRs #879, #882 β see also Observability and Telemetry configuration.
Busy-as-mutex refinements β $self, one lock per accept, subflow chain reserve (#883, #884)β
Three corrections to the execution model introduced in v0.0.79.
1. A $self transition no longer re-runs the state's lifecycle. A transition targeting $self changes no state, but it still ran the current state's OnExit, the target state's OnEntry a second time, and cancelled and re-armed the state's scheduled transitions β silently restarting every timeout from zero. Most visible on updateData, whose target is fixed to $self by WorkflowValidator.
The fix is a self-target profile composed on top of the trigger's profile (Manual+Self, AutoChain+Self, β¦), not a sixth profile you select instead. It adds four exclusions:
| Excluded step | Why |
|---|---|
| CancelScheduledJobs (39) | The state isn't left; tearing its timers down loses them |
| OnExit (40) | No state is left |
| OnEntry (60) | No state is entered; hooks already ran on first arrival |
| Schedule | Re-arming restarts every timeout |
ChangeState (50) and OnExecute (30) are deliberately kept in. ChangeState is the only step that sets context.Target, which the auto-transition step reads β exclude it and the auto step returns at its first guard and the transition advances nothing. OnExecute is the transition's own work, not the state's lifecycle.
Two details matter for authors:
- Only the authored
$selfkeyword counts. A literal target that happens to equal the current state is not treated as$self, becausetarget == currentStateis a coincidence produced by three unrelated mechanisms and means "no state change" in only one of them: a start pre-positions a new instance into its initial state before dispatching the start transition (the state still needs entering); a retry after a partial commit finds the instance already committed in the target state (the retry exists to redo exactly that step); and a genuine self-loopfrom: A, target: A. An earlier revision using the literal comparison killed the initial state'sOnEntryoutright and turned retry into a no-op. Authors who want no-state-change semantics must write$self. - The profile applies to
updateDataonly (#884). A$selfshared transition was silently losing its state'sOnExit/OnEntryand its timer re-arm;SkipsStateLifecycle()now composes the target check with the updateData check, so every other$selftransition keeps the base profile and runs the full lifecycle.
2. One lock per accept. The Busy flag is the mutex, so a lock belongs only at the status check-and-set β but the async path took a second lock ({LockKey}:enqueue) and nested the status lock inside it, paying two Postgres-backed round trips per accept. ITransitionAdmissionService.AcceptAsync now takes a single lock on ctx.LockKey and runs the kind's status flip, the duplicate-active-job guard and the durable enqueue under it. The guard has to stay in that critical section: no DB constraint backs its check-then-insert, and a partial unique index cannot replace it, because a $self auto loop and a re-armed scheduled transition both legitimately hold two active rows for the same (InstanceId, JobType, SourceState, TransitionKey) tuple. cancel / exit / timeout stay exempt from the Busy 409 but now flip Busy at the accept rather than in the pipeline β so the moment a client observes Busy moves earlier.
3. The subflow chain is reserved at accept. An async transition accepted on a parent holding an open SubFlow correlation answered 202 without marking anything Busy. Because the state function reports the deepest active subflow, a client long-polling the parent still saw the leaf Active, concluded nothing was in progress, and stopped driving the flow. AsyncTransitionStrategy now calls ReserveSubflowChainAsync on the subflow-forward branch, marking the chain down to the leaf before the 202 commits; the relay then claims that reserve so the leaf does not reject it with Instance:100031 for the Busy the accept just set. The claim is threaded accept β TransitionJobPayload.SubflowChainReserved β ForwardToActiveSubflowStep β ForwardToSubflowJob β handler, and is deliberately narrower than IsPreReserved so a sync-origin or cancel/exit/timeout relay cannot barge past a leaf that is Busy for its own reasons. Cross-domain hops use a new internal-only POST .../internal/subflow-forward (with a matching busy-release) rather than the public transition endpoint, which copies caller headers unfiltered and would make the claim forgeable. The sync path deliberately does not chain-reserve: a blocking caller cannot observe a stale Active, so reserving would only widen stranded-Busy.
Reference: PRs #883, #884 β see also Transition pipeline and Workflow component.
Behavior Changesβ
Two items in this release change observable behavior. A $self transition (in practice, updateData) no longer re-runs OnExit / OnEntry and no longer re-arms the state's scheduled transitions β domains relying on that refresh must move the logic into the transition's own onExecutionTasks, and domains relying on the timer reset must model the reset explicitly. And cancel / exit / timeout now flip Busy at accept time rather than in the pipeline, so the moment a client observes Busy moves earlier. Both items, their impact direction and the migration steps are documented in the v0.0.80 breaking changes announcement. Grep your domain definitions for "target": "$self" before upgrading.
Fixesβ
- A state change from a state to itself no longer reports as a change β
ChangeStateStepstopped emitting its state-change metric, log and span event, andInstance.ChangeStatestopped publishingsub:state-changed, whenprevious == new. Reporting a change from a state to itself was a false signal that reached dashboards and subflow parents alike (#883). - Three stale
JobName.ForScheduledTransitioncall sites fixed β the Domain test project did not compile onmasterand took the Application tests down with it, so the suite was unbuildable (#883). - Compensation on a failed enqueue releases only what it flipped β the accept path's compensation no longer releases Busy an unrelated holder set (#884).
Configuration Updatesβ
Configuration for v0.0.80:
{
"runtimeVersion": "0.0.80",
"schemaVersion": "0.0.52"
}
Note: Schema version is unchanged at 0.0.52 β nothing in this release adds or changes an authored component field.
Aether 1.0.35 is required. sub / act_sub were surfacing as requestheader_act_sub, because Aether hard-coded a RequestHeader. prefix and backends that cannot store dots (OpenObserve, Elasticsearch) lowercase and flatten it. LoggingEnricherOptions.RequestHeaderKeyPrefix is now configurable and every host sets it to the empty string, so the fields land as sub, act_sub, jti, role, x_parent_instance_id, user_agent:
{ "Telemetry": { "Logging": { "Enrichers": { "RequestHeaderKeyPrefix": "" } } } }
The response prefix keeps its default, so request and response values cannot collapse onto one field. The setting does nothing on Aether 1.0.34.
Gateway (APISIX): the request-id plugin must send X-Request-Id and the opentelemetry plugin must send traceparent, or the client hop is missing from both the log query and the trace tree. X-Request-Id is deliberately kept out of Telemetry:Logging:Enrichers:Headers and Telemetry:Tracing:Headers: on platform-originated requests Aether's middleware generates an id from HttpContext.TraceIdentifier and writes it back into the request headers, and the enricher would then emit that fabricated value under the same key and suppress the real one. RequestIdLogProcessor and RequestIdSpanProcessor are the single source for the field.
Dashboards: enricher fields lose their RequestHeader. prefix (requestheader_act_sub β act_sub); saved queries and dashboard panels need updating.
CI: NuGet publishing moved to trusted publishing β NuGet/login@v1 exchanges the job's OIDC token for a one-hour API key, and no long-lived publishing secret remains in the workflow.
Container images: published at tag 0.0.80 under ghcr.io/burgan-tech/vnext/*, Cosign-signed (keyless OIDC) with SBOM + provenance. Immutable digests are listed in the GitHub release.
Issues Referencedβ
- vnext #876 β Expose scheduled transitions with a persisted UTC execution time and fold the job set into the ETag.
- vnext #879 β Propagate workflow correlation context across the remote task execution boundary.
- vnext #882 β End-to-end correlation and distributed tracing across the platform.
- vnext #883 β Skip state lifecycle steps for
$selftransitions. - vnext #884 β Reserve the subflow chain at accept with one lock, and scope the
$selfprofile toupdateData.
Summaryβ
scheduledTransitionsin the state response:name,kind: "scheduled", persistedexecuteAtUtc, ordered by execution time, folded into the fingerprint ETag; response shape v6; migrationAddInstanceJobExecuteAt.- Four correlation carriers β trace context,
X-Request-Id,X-Correlation-Id,X-Workflow-Instance-Idβ propagated through jobs, events and outbound tasks; reserved headers cannot be set by a task binding; identity claims are fill-if-absent. - One trace tree per client request; pipeline step spans are Verbose-only by construction.
$selfskips the state lifecycle β only forupdateData, and only for the authored keyword, never a literal same-state target.- One lock per accept;
cancel/exit/timeoutflip Busy at accept. - Subflow chain reserved at accept on the async forward path, with an internal-only
subflow-forward/busy-releasepair for cross-domain hops. - Requires Aether 1.0.35 and
Telemetry:Logging:Enrichers:RequestHeaderKeyPrefix: ""; dashboards lose theRequestHeader.prefix. - Schema stays at 0.0.52.
vNext Runtime Platform Team Released August 17, 2026
