Skip to content

Instantly share code, notes, and snippets.

@mjameswh
Last active September 17, 2026 20:43
Show Gist options
  • Select an option

  • Save mjameswh/a2bb80c00daea9dbf869752904e89130 to your computer and use it in GitHub Desktop.

Select an option

Save mjameswh/a2bb80c00daea9dbf869752904e89130 to your computer and use it in GitHub Desktop.
Event Groups — Test Plan
title Event Groups Design
date 2026-09-14

Overview

Event Groups introduces a way to group logically related Workflow Events, providing customers with improved visibility, analysis, and debugging capabilities.

By allowing Workflow Events to be grouped according to user-defined or system-inferred criteria, users can navigate and visualize timelines with greater clarity.

Goals

Initial goals (MVP features)

Introduction of the Event Group primitive in Workflow APIs

  • Introduce a Workflow-side API to explicitly create Event Group Markers.
  • Allow explicitly attaching Event Group Markers to commands produced by a workflow, similar to how one can already attach “summary” and “details” metadata to commands.
  • Allow attaching Event Group Markers to a workflow code’s execution scope, automatically propagating through coroutines to all commands created from within that scope.
  • Automatic creation of an implicit Event Group wrapping each inbound signal and update. Combined with execution scope based propagation of Event Group Markers, this allows correlation of an external trigger with its consequent workflow commands.

Enriched Workflow History visualization in Temporal UI

  • Provide enriched workflow history visualization that leverages Event Group Markers.

    Initial visual enrichments of the workflow history are left to be decided; they could notably include:

    • Colored labels next to events corresponding to each Event Group;
    • Reordering of events display so that events that belong to a same Event Group are visually displayed together, rather than interleaved with events from other Event Groups;
    • Filtering the workflow history events view based on Event Group (e.g. clicking on the colored label next to the event could gray out events that don’t belong to that group).

Eventual goals

Cancellation of commands based on Event Groups

  • Allow cancellation of commands based on Event Groups; i.e. in UI, a user clicks on an Event Group and selects “Cancel all commands in this group”.

Tracking of runtime relationship between sibling coroutines

  • Automatic tracking of runtime relationship between events of a same group (i.e. in group X, completion of event Y resulted in the following event Z).
  • Automatic creation of Event Group Markers on Temporal failures.
  • Risk: Feasibility and runtime cost of this feature may vary depending on the language and SDK.

Non-goals

Propagating Event Groups across workflow executions and from client APIs

The Event Groups primitive is meant to provide detailed tracking of the intra-workflow execution graph. It would however be inefficient as a visibility mechanism for cross-workflow interactions, and would be redundant with other visibility primitives, notably the recently introduced Link mechanism on history events which already addresses a fundamentally equivalent use case. See Why Event Groups are scoped to a single workflow execution for the complete rationale.

Consequently, the following are considered non-goals for this proposal:

  • Propagate Event Groups across workflow boundaries — i.e. Markers attached to a signalExternalWorkflow, startChildWorkflow, continueAsNew, etc. command are recorded on the source workflow's history, but do not flow to the target/successor workflow.
  • Provide Event Group Markers when invoking workflows through the Workflow Service Client API.
  • Extending Event Groups to non-Workflow execution primitives (Nexus Operations, Standalone Activities, Schedules, Batches, etc).

Future work could explore the potential of a streamlined API that combines the various visibility primitives offered by the Temporal platform.

Requirements

  • Event Groups must introduce no new Workflow Command or History Event types; they have to be carried as new fields on existing Commands and History Events.
  • There might be multiple Event Groups arbitrarily attached to a single command/history event.
  • A user may create multiple, distinct Event Groups with a same label in a single workflow execution.
  • Event Groups may optionally carry user-provided labels and/or other forms of metadata; such metadata must be treated as sensitive content and must therefore be processed through the user provided payload codecs (similar to the existing summary and details metadata fields).
  • It must be possible for the UI to eagerly determine Event Group attributions while fetching a workflow's history, without requiring or waiting for the decoding of Marker payloads. For example, the UI could initially present Event Groups as simple colored balls (without text label), and then do payload decoding lazily.
  • Modification of existing Workflow code that results in producing different Event Group Markers on replay (i.e. addition/change/removal of Event Group Markers):
    • Must not cause a Non-Determinism Error;
    • Must not result in incorrect attributions (i.e. an event being attributed to a group to which it does not belong);
    • Must not otherwise break other unrelated Event Group attributions.
    • Occasional missing attributions and split brains are undesirable but tolerable.
  • Event Groups representation in history must be compact and efficient; more specifically, attaching an Event Group with non-trivial metadata payloads to a large number of events should not unnecessarily duplicate the Event Group's metadata payloads for each event, nor significantly increase the size of the history event.

Proposed Design

Concepts

The design rests on two concepts:

  • A primitive — the Event Group Marker — that the SDK and protocol manipulate;
  • A derived construct — the Event Group — that users and the UI reason about.

Event Group Marker

An Event Group Marker (or simply "Marker") is a discrete, opaque token that the SDK attaches to commands and history events. Internally, a Marker is identified by an opaque id, and may optionally carry a user-visible label payload.

Markers are not commands of their own: they are piggy-backed as a new field on existing commands and history events, so introducing them does not require any new Workflow Command or History Event type. A single Marker may be attached to multiple commands/events, and a single command/event may carry multiple Markers (M-to-N).

Markers can be created explicitly by the user through an SDK API, or implicitly by the SDK — for example, on inbound signal and update reception. In both cases, the SDK simply adds the Marker to the commands it produces, and the server transcribes it onto the corresponding history events.

Event Group

An Event Group is the user-facing, conceptual construct built on top of Markers: it is the set of all events that carry the same Marker ID. Groups are not mutually exclusive: a single event may belong to several Event Groups at once. From the user's perspective, a group is typically perceived as an execution scope with a "start" and an "end". From the system's perspective, however, there is no dedicated start or end event — a group exists only implicitly, as the collection of events sharing a common Marker.

In practice, "Event Group" is primarily a UI and mental-model concept; the runtime and the protocol operate exclusively on Markers.

2-minute overview

Implicit Event Groups

export async function myWorkflow() {
  await sleep('5m'); // Sees no Event Group

  // Implicit Event Group wrapping inbound signal handlers
  setHandler(mySignal, async () => {
    await sleep('5m'); // Sees "mySignal@event-id=123"
  })
}

Direct attachment of an Event Group

export async function myWorkflow() {
  // User explicitly creates an Event Group...
  const myGroup = createEventGroup('my-group');

  await myActivity.executeWithOptions( // Sees "my-group"
    {
      // ...
      eventGroups: [myGroup] // ... then attaches it to specific commands
    },
    [...]   // Activity args
  )
}

Event Groups as execution scopes

export async function myWorkflow() {
  // User explicitly creates an Event Group...
  const myGroup = createEventGroup('my-group');

  // ... then execute code within that Event Group's scope.
  await myGroup.withScope(async () => {
    // All commands executed within this scope are
    // automatically associated with the Event Group.
    await myActivity1(...); // Sees "my-group"
    await myActivity2(...); // Same
  });
}

Protobuf API Changes

temporal/api/sdk/v1/event_group_marker.proto (new message)

message EventGroupMarker {
  // What this Marker represents. The variant determines whether the Marker was
  // created explicitly by user code (label) or implicitly by the SDK on inbound
  // signals/events (inbound_event) or update handlers (inbound_update).
  oneof variant {
    Label label = 1;
    InboundEvent inbound_event = 2;
    InboundUpdate inbound_update = 3;
  }

  // A user-defined grouping key with optional metadata.
  message Label {
    // A stable, plaintext grouping key for this Label.
    //
    // This ID will be attached to every history event attributed to this
    // Event Group. Use of short IDs is highly recommended (~40 chars at most).
    // Must be non-empty.
    string id = 1;

    // Optional payload-encoded text to be displayed by the UI.
    //
    // When set, this should be a "json/plain"-serialized, codec-encoded payload
    // that is a single JSON string. User interface formatting may apply.
    // 
    // The payload data section is limited to 400 bytes by default.
    //
    // Payload only needs to be set on the first use of a given Label ID;
    // further references to an existing Label ID reuse existing attributes of
    // the referenced Label – i.e. further label payloads are ignored.
    //
    // If no label payload is provided, or if payload can't be decoded, the UI
    // should display the Label ID instead.
    //
    // Note that it is valid to have distinct Markers (i.e. distinct Marker IDs)
    // in a given workflow execution that carry the same label, provided that
    // they have distinct IDs.
    temporal.api.common.v1.Payload label = 2;
  }

  // The event ID of an event in the present workflow that triggered implicit
  // creation of this group Marker.
  //
  // The target event's type must be `WORKFLOW_EXECUTION_SIGNALED`.
  message InboundEvent {
    int64 inbound_event_id = 1;
  }

  // The identifier of an inbound Update (request.meta.update_id)
  // whose handler triggered implicit creation of this group Marker.
  //
  // Used in place of `inbound_event_id` for Updates because the event ID of the
  // UpdateAccepted history event is not known until the Workflow Task is
  // completed and recorded by the server, which may be too late.
  message InboundUpdate {
    string inbound_update_id = 1;
  }
}

temporal/api/command/v1/message.proto (existing message)

 message Command {
   // ... (existing fields) ...
   temporal.api.sdk.v1.UserMetadata user_metadata = 301;
+  repeated temporal.api.sdk.v1.EventGroupMarker event_group_markers = 302;
   // ...
 }

temporal/api/history/v1/message.proto (existing message)

 message HistoryEvent {
   // ... (existing fields) ...
   temporal.api.sdk.v1.UserMetadata user_metadata = 301;
   repeated temporal.api.common.v1.Link links = 302;
   temporal.api.common.v1.Principal principal = 303;
+  repeated temporal.api.sdk.v1.EventGroupMarker event_group_markers = 304;
   // ...
 }

SDK Changes Overview

Explicit Event Groups

Here is a high-level overview of the user-facing APIs we'll add or modify in each SDK. See the Per-Language SDK Details section for exact proposition for each language.

  • Add a new EventGroup interface.
    • Exported from the "workflow" package.
  • Add a createEventGroup(id: string, options?: { label?: string }): EventGroup factory function that creates a new Event Group with a given ID.
    • Exported from the "workflow" package.
    • id must be a non-empty string.
    • label is optional. If omitted, the Marker carries no label payload, and the UI should display the ID instead. If provided, it must be a non-empty string.
  • Add an eventGroups?: EventGroup[] option on the following existing APIs:
    • newTimer, sleep;
    • condition(fn, duration);
    • scheduleActivity, executeActivity;
    • scheduleLocalActivity, executeLocalActivity;
    • startChildWorkflow, executeChildWorkflow;
    • startNexusOperation, scheduleNexusOperation;
    • signalExternalWorkflow;
    • patches (Core-based SDKs only);
    • sideEffect, mutableSideEffect (Go and Java only);
    • continueAsNew;
    • other APIs to be confirmed.
  • Add an SDK-provided scope helper that runs a function within an Event Group's scope.
    • All commands produced from within the scope automatically carry the group's Marker.
    • Each SDK exposes the shape and vocabulary that is most idiomatic for its language. Refer to the Per-Language SDK Details section.
    • Nested scopes are supported and compose: a command produced inside an inner scope carries the union of all enclosing scopes' Markers. The order of Markers is unimportant.
    • There is currently no intent to provide a user-facing API surfacing "active" Event Groups.
  • If a label is provided, it must be converted to a Payload as a JSON string using the SDK’s default Payload Converters, rather than user-provided Converters, and then processed through the user-provided Payload Codec, if any.

Implicit Event Groups

  • Add creation of an implicit Event Group wrapping calls to Signal handlers.

    • This implicit Event Group Marker will be tied to the event id of the WorkflowExecutionSignaled event.
    • Divergence: The existing Go SDK's signal handling API does not expose a per-signal context. Signal handlers simply reuse the workflow's context. It will consequently not be possible to create implicit Event Groups on signals in that SDK unless we introduce a new signal handling API (e.g. callback-based, similar to the Update Handler API).
  • Add creation of an implicit Event Group wrapping calls to Update handlers.

    • This implicit Event Group Marker will be tied to the Update ID (i.e. Request.meta.update_id).
    • This Event Group should only wrap the call to the Update's handler itself, not the call to the Update's validator function.
  • The implicit Event Groups described above do not propagate any outer Event Groups to their inner execution scope – this is particularly significant in the case of dynamically registered Signal and Update handlers.

    Save for that exception, explicit Event Groups created in user code compose with these implicit scopes per the nesting rule described previously (i.e. a command produced inside an explicit scope nested in a handler carries both Markers).

    For example:

    async function myWorkflow(): Promise<void> {
      const groupA = createEventGroup('GroupA');
      await groupA.withScope(async () => {
        await sleep(); // <-- "GroupA"
    
        setHandler(mySignal, async ()=>{
          const groupB = createEventGroup('GroupB');
          await groupB.withScope(async () => {
            await sleep(); // <-- "signal@eid=123" and "GroupB"
                           //     but not "GroupA"
          });
        });
      });
    }

History Consolidation

Per design requirements, adding, removing or changing Event Group Markers attached to a command:

  • must never raise a Non-Determinism Error;
  • must never cause an event to be attributed to a group it does not belong to;
  • may result in missing attributions or split groups (undesirable but tolerable).

In other words, Event Group Markers must be identified in a way that remains as stable as possible across workflow replay and minor code edits, with the guarantee that distinct groups are never incorrectly considered equal.

That notably rules out the use of sequential internal IDs (like the ones used to identify timers, activities and other commands), as those would be subject to shifting and divergence on replay.

Implicit Event Groups

Implicit Event Groups — those tied to signal or update handlers — use the corresponding event ID or update ID as their identifier.

Explicit Event Groups (Label)

Explicit Event Groups created via createEventGroup(id, { label? }) take a user-provided identifier (the id argument). Users are responsible for ensuring that this identifier is stable across workflow replays and minor code edits.

IDs are stored in plaintext in the Workflow's history, i.e. they are not codec-encoded. Users are responsible for ensuring that these IDs do not contain any sensitive content.

The SDK does not enforce any uniqueness constraint on IDs or Labels. That notably means that:

  • Users may create multiple distinct groups that share display text by passing different IDs and the same label.
  • Users may create multiple EventGroup objects that will regroup to a single conceptual Event Group in the UI by passing the same ID. This may notably be used in cases where a user wants multiple commands to be associated with a same Event Group, but find it inconvenient to reuse the EventGroup object itself across their codebase.

Per-Language SDK Details

The sections below describe each SDK’s proposed public API shape and language-specific considerations. Common behavior is defined in SDK Changes Overview.

TypeScript SDK

Highlights

  • Event Group Markers can be explicitly created with createEventGroup(id) or createEventGroup(id, { label: string })
  • Scope-based propagation through eventGroup.withScope(fn)

Code sample

import * as wf from '@temporalio/workflow';

export async function myWorkflow() {
  // Creation of explicit Event Groups
  const paymentGroup = **wf.createEventGroup('payment-processing')**;
  const customerGroup = **wf.createEventGroup('customer-123456', { label: 'Customer: John Doe' })**;

  // Explicit attachment of event groups to workflow commands
  await myActivity.executeWithOptions(
    {
      startToCloseTimeout: '10s',
      **eventGroups: [paymentGroup, customerGroup]**,
    },
    args,
  );
  
  // Scope-based propagation. Only singular form.
  **await paymentGroup.withScope(**async () => {
    await authorizePayment(...);
    await capturePayment(...);
  }**)**;
}

Python SDK

Highlights

  • Event Group Markers can be explicitly created with workflow.create_event_group(id) or workflow.create_event_group(id, label="...").
  • Scope-based propagation through with event_group.scope().

Code sample

@workflow.defn
class MyWorkflow:
    @workflow.run
    async def run(self) -> None:
        # Creation of explicit Event Groups
        payment_group = **workflow.create_event_group("payment-processing")**
        customer_group = **workflow.create_event_group("customer-123456", label="Customer: John Doe")**

        # Explicit attachment of event groups to workflow commands
        await workflow.execute_activity(
            my_activity,
            arg,
            **event_groups=[payment_group, customer_group]**,
        )
  
        # Scope-based propagation. Allows plural form.
        **with payment_group.scope(), customer_group.scope():**
            await authorize_payment(...)
            await capture_payment(...)

Rust SDK

Highlights

  • Explicit Event Groups are created with EventGroup::new(id). An optional display label is added with .with_label(label).
  • Various Command Options structs get an extra event_groups: Vec<EventGroup> property, which is exposed in Bon-derived builders as event_groups(groups: impl IntoIterator<Item = EventGroup>).
  • Scope-based propagation through context derivation: ctx.with_event_group(group: impl Borrow<EventGroup>) or ctx.with_event_groups(groups: impl IntoIterator<Item = EventGroup>) returns a derived context, and every command issued through that context carries the group's Marker.

Code sample

#[workflow_methods]
impl MyWorkflow {
    #[run]
    pub async fn run(ctx: &mut WorkflowContext<Self>) -> WorkflowResult<()> {

        // Creation of an explicit Event Group
        let payment_group**: EventGroup = EventGroup::new("payment-processing");**
        
        // Creation of an explicit Event Group with a display label
        let customer_group**: EventGroup = EventGroup::new("customer-123456")
                                             .with_label("Customer: John Doe");**

        // Explicit attachment of Event Groups to workflow commands
        ctx.execute_activity(
            MyActivities::charge_card,
            args,
            ActivityOptions::with_start_to_close_timeout(Duration::from_secs(10))
                **.event_groups([payment_group.clone(), customer_group.clone()])**
                .build(),
        )
        .await?;

        // Scope-based propagation. Also plural: **with_event_groups([...])**
        let mut scoped = **ctx.with_event_group(payment_group)**;
        authorize_payment(&mut scoped).await?;
        capture_payment(&mut scoped).await?;

        Ok(())
    }
}

.NET SDK

Highlights

  • Explicit Event Groups are created through the workflow context: Workflow.CreateEventGroup(id), or Workflow.CreateEventGroup(id, new() { Label = "..." }).
  • Various command Options classes get an extra EventGroups property, of type IReadOnlyCollection<EventGroup>?.
  • Scope-based propagation through using (Workflow.WithEventGroups(group, …)), which returns a disposable EventGroupScope: IDisposable. Scope propagation is backed by an AsyncLocal.
  • EventGroup and EventGroupScope are public sealed class with internal constructors to prevent misconstructions.

Code sample

[Workflow]
public class PaymentWorkflow
{
    [WorkflowRun]
    public async Task RunAsync()
    {
        // Creation of an explicit Event Group
        EventGroup paymentGroup = **Workflow.CreateEventGroup("payment-processing")**;

        // Creation of an explicit Event Group with a display label
        EventGroup customerGroup = **Workflow.CreateEventGroup(
            "customer-123456", new() { Label = "Customer: John Doe" })**;

        // Explicit attachment of Event Groups to workflow commands
        await Workflow.ExecuteActivityAsync(
            (PaymentActivities act) => act.ChargeCard(chargeRequest),
            new()
            {
                StartToCloseTimeout = TimeSpan.FromSeconds(10),
                **EventGroups = new[] { paymentGroup, customerGroup }**,
            });

        // Scope-based propagation. Allows plural form.
        **using (Workflow.WithEventGroups(paymentGroup, customerGroup))**
        {
            await AuthorizePaymentAsync();
            await CapturePaymentAsync();
        }
    }
}

Ruby SDK

Highlights

  • Explicit Event Groups are created through Temporalio::Workflow.create_event_group(id), or Temporalio::Workflow.create_event_group(id, label: '…').
  • Every command-producing workflow API gains an event_groups: keyword argument, accepting an Array of Event Groups.
    • For the wait-condition-with-timeout use case, the event_groups: keyword argument is passed to the timeout method, rather than the wait_condition method, similar to how cancellation: and summary: are already passed.
  • Scope-based propagation through Temporalio::Workflow.with_event_groups(group) { … }, which accepts one or more groups. This is backed by Ruby's Fiber storage.

Code sample

class PaymentWorkflow < Temporalio::Workflow::Definition
  def execute
    # Creation of an explicit Event Group
    payment_group = **Temporalio::Workflow.create_event_group('payment-processing')**

    # Creation of an explicit Event Group with a display label
    customer_group = **Temporalio::Workflow.create_event_group(
      'customer-123456',
      label: 'Customer: John Doe'
    )**

    # Explicit attachment of Event Groups to workflow commands
    Temporalio::Workflow.execute_activity(
      ChargeCardActivity,
      charge_request,
      start_to_close_timeout: 10,
      **event_groups: [payment_group, customer_group]**
    )

    # Scope-based propagation
    **Temporalio::Workflow.with_event_groups(payment_group, customer_group) do**
      authorize_payment
      capture_payment
    end
  end
end

Go SDK

Highlights

  • Explicit Event Groups are created through workflow.NewEventGroup(id), or workflow.NewEventGroupWithOptions(id, workflow.EventGroupOptions{Label: "…"}).
  • Scope-based propagation through context derivation: workflow.WithEventGroups(ctx, groups...) returns a derived context, and every command issued through that context carries the groups markers.
  • Divergence: Go's channel-based signal delivery has no per-signal execution scope, so implicit Event Groups on inbound signals cannot be expressed against today's public API. Implicit groups therefore cover Update handlers only; see "Language-specific considerations" below.

Code sample

func PaymentWorkflow(ctx workflow.Context, req ChargeRequest) error {
	// Creation of an explicit Event Group
	paymentGroup := **workflow.NewEventGroup("payment-processing")**

	// Creation of an explicit Event Group with a display label
	customerGroup := **workflow.NewEventGroupWithOptions("customer-123456",
		workflow.EventGroupOptions{Label: "Customer: John Doe"})**

	// Explicit attachment of Event Groups to workflow commands
	actx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
		StartToCloseTimeout: 10 * time.Second,
		Summary:             "charge the customer's card",
		**EventGroups:         []workflow.EventGroup{paymentGroup, customerGroup},**
	})
	if err := workflow.ExecuteActivity(actx, ChargeCard, req).Get(actx, nil); err != nil {
		return err
	}

	// Scope-based propagation through context derivation
	**gctx := workflow.WithEventGroups(ctx, paymentGroup, customerGroup)**
	if err := authorizePayment(gctx); err != nil {
		return err
	}
	return capturePayment(gctx)
}

Language-specific considerations

The Go SDK delivers inbound signals by pushing them into a buffered workflow.Channel; user code receives them later, from whichever coroutine happens to hold whichever Context it holds. There is no per-signal execution scope for the SDK to wrap, and no context that a signal's implicit Marker could be installed on.

Three ways to close that gap:

  • Leave it open. Ship implicit Event Groups for the Update handlers only, and document that signals carry none.
  • Derive a Context at the point of receipt. Carry the signal's event ID alongside its payload and return a derived Context from the receive operation, so that commands issued with that Context carry the signal's implicit Marker. This is idiomatic Go and opt-in, but it requires a new spelling for signal receipt, since adding a method to the existing public ReceiveChannel interface would be a breaking change.
  • Add a callback-based signal handler API, mirroring the existing Update handler API. Implicit Event Groups on signals then follow from the same mechanism that serves Update handlers. This is a substantially larger change than Event Groups itself, and worth pursuing on its own merits rather than as a consequence of this proposal.

Java SDK

Highlights

  • Explicit Event Groups are created through Workflow.createEventGroup(id), or Workflow.createEventGroup(id, EventGroupOptions). EventGroupOptions carries the optional display label via setLabel.
  • Scope-based propagation through eventGroup.run(…), which takes the block to run as either a Functions.Proc or a Functions.Func<R>. Nested scopes compose.
  • The Options classes that already carry summary metadata gain an addEventGroups(EventGroup…) builder method, which accumulates rather than replacing, so a builder derived from existing options extends that base's groups.
  • APIs that take no options object at all — Workflow.sleep, Workflow.await, getVersion, the upsert… calls, and signaling an external or child workflow — carry Event Groups through scope propagation only.
  • EventGroup is public final class with a protected constructor to prevent misconstructions.

Code sample

public class PaymentWorkflowImpl implements PaymentWorkflow {

  @Override
  public void processPayment(ChargeRequest request) {
    // Creation of an explicit Event Group
    EventGroup paymentGroup = **Workflow.createEventGroup("payment-processing");**

    // Creation of an explicit Event Group with a display label
    EventGroup customerGroup =
        **Workflow.createEventGroup(
            "customer-123456",
            EventGroupOptions.newBuilder()
                             .setLabel("Customer: John Doe")
                             .build()
        );**

    // Explicit attachment of Event Groups to workflow commands, beside the summary
    PaymentActivities activities =
        Workflow.newActivityStub(
            PaymentActivities.class,
            ActivityOptions.newBuilder()
                .setStartToCloseTimeout(Duration.ofSeconds(10))
                .setSummary("charge the customer's card")
                **.addEventGroups(paymentGroup, customerGroup)**
                .build());
    activities.chargeCard(request);

    // Scope-based propagation. Only accepts singular form.
    **paymentGroup.run(**
        () -> {
          authorizePayment(request);
          capturePayment(request);
        });
  }
}

Core—Language SDK Architecture

Core Responsibilities

  • Workflow Activation messages for SignalWorkflow jobs are modified to carry the Event ID of the corresponding history event.
  • Workflow Command messages are modified to accept an array of Event Group Markers attached to the command.
  • Core propagates the Event Group Markers from the Language-side Commands to the server-side Commands.
  • Dedup the label field when emitting protobuf temporal.api.sdk.v1.EventGroupMarker.Label messages for IDs that have already been declared in that workflow.

Language SDK Responsibilities

  • Creation of implicit Event Group Markers for inbound signals and inbound updates, and execution of handlers within the scope of those implicit Event Groups.
  • Tracking of Event Groups execution scopes (i.e. using AsyncLocalStorage or other applicable context propagation primitives for the given language).

Core Protocol Changes

local/sdk/temporal/sdk/core/workflow_activation.proto (existing messages)

message SignalWorkflow {
  // ... (existing fields) ...
   map<string, temporal.api.common.v1.Payload> headers = 5;

   // Event ID of the `WORKFLOW_EXECUTION_SIGNALED` history event
   // that produced this job.
   **int64 originating_event_id = 6;**
}

local/sdk/temporal/sdk/core/workflow_commands.proto (existing messages)

message WorkflowCommand {
  // ... (existing fields) ...
  temporal.api.sdk.v1.UserMetadata user_metadata = 100;

  // Event group markers attached to the command. These are forwarded
  // onto the corresponding server-side `Command` message, and then
  // surfaced on the resulting `HistoryEvent`.
  **repeated temporal.api.sdk.v1.EventGroupMarker event_group_markers = 101;**

  // ... (existing fields) ...
}

Open Questions

Which Event Group Markers should be captured on Cancellation commands?

TBD. Cancellation commands are often generated by the SDK rather than written by user code, so it is not yet specified whether they should carry the markers of the command being cancelled, markers from the cancel call site, both, or neither.

Alternatives and Rationales

Why are Event Groups scoped to a single workflow execution?

There is a real use case for cross-workflow visibility — for example, identifying that a chain of related actions across workflows A, B and C all originated from a single user-initiated operation.

However, on closer examination, the granularity provided by Event Groups is better suited to grouping events within a single workflow execution than to expressing relationships across executions.

Trying to propagate Event Group Markers across workflow boundaries would bring several challenges:

  • The per-event footprint would grow disproportionately. A cross-workflow Marker would need to carry enough information to identify its workflow of origin (namespace, workflow ID, run ID), which is significantly more expensive than an intra-workflow opaque ID. With multiple Markers active at the point of a cross-workflow command, and with Marker sets compounding across hops, the cost per history event would become effectively unbounded.
  • Marker granularity collapses at the boundary. When workflow A sends a signal to workflow B, every Event Groups active on A's outbound event is equally responsible for the signal being sent — there is no semantically meaningful way to propagate a subset. From workflow B's perspective, a single backlink from B's inbound event to A's outbound event is therefore as informative, for navigation purposes, as carrying the full Marker set.
  • Cross-workflow correlation without indexation has limited value. Reconstructing a cross-workflow group view requires walking the relationship graph across multiple histories. Indexing Markers in the visibility store would impose a constant write-time cost that the vast majority of workflows would never benefit from; performing the same traversal lazily in the UI — only when a user actually navigates between workflows — is a more appropriate tradeoff.

The same considerations apply to allowing clients to attach Event Group Markers when invoking workflows (start, signal, update) through the Workflow Service Client API. Without indexation, Markers supplied to multiple otherwise-unrelated workflows could not be correlated, which would leave the feature largely unusable for its intended purpose.

We believe that these use cases may be better addressed using other visibility primitives that the Temporal platform already provides, notably Link, SearchAttributes, UserMetadata, Headers, etc. Rather than attempting to extend Event Groups into a position where they would compete with other primitives, we propose to focus on the core use case of grouping events within a single workflow execution, for which there is no immediate solution.

Future work could explore the potential of a streamlined API that combines the various visibility primitives offered by the Temporal platform.

Why Event Group IDs are caller-provided and plaintext

Explicit Event Groups created via createEventGroup(...) pose particular challenges from an history consolidation and the UI's regrouping logic, due to the conjunction of the following constraints:

  • The label may contain sensitive data and must therefore be payload-encrypted.
  • It must be possible for the UI to eagerly regroup events per Event Group, without having to decode label payloads.
  • We can't assume that payload encryption is a deterministic transformation (i.e. that encrypting the same label multiple times would result in the same sequence of bytes).
  • Minor code edits to the workflow code that add, remove, reorder or modify usage of Event Groups must not require the use of workflow code versioning features, and must not result in misattribution of Event Groups.

For those reasons, labels themselves can't be used as the grouping key, hence the need for an ID. It also means that the IDs themselves can't be assigned from a sequential counter, as we generally do for commands identifiers, as these would be subject to shifting and divergence on replay when the workflow code is later modified, which could result in misattribution of Event Groups.

A former version of this design proposal made "label" be the primary, mandatory argument of createEventGroup(...), deriving an Event Group's default ID value from a hash of its label (i.e. HEX(SHA1(runId + label)), where runId is the value of the WorkflowExecutionStarted.original_execution_run_id field, which remains stable on reset).

That initial direction was motivated by the belief that exposing only one string would make this API simpler for the user, and that this single string may contain sensitive data, so that string should be the Label rather than the ID (i.e. that would apparently make it "secure by default").

It was however later recognized that this approach presents some serious issues:

  • The proposed derivation formula, and most other similar formulas, would produce a public fingerprint of the label that was cheap to brute-force for short and/or predictable label strings, which severely reduces the viability of using sensitive data as a label, or otherwise required that users systematically provide a non-sensitive ID for every Event Group they create.
  • An Event Group's label's primary purpose is to be displayed in the UI, and may therefore evolve over time as the user's visibility needs change. IDs derived from such a label may therefore be unstable over time. By allowing users to only specify the label's text, rather than providing an ID, we're not encouraging them to be intentional about the long term stability requirements of the Event Group's grouping key, but instead let that be an afterthought issue to be addressed when they later need to modify their workflow code.

Concretely, that means that most users of the Event Groups API would effectively need to properly understand those intrinsic subtleties from day one—rather than make this a concern only for more advanced users—and that most users should really have both an ID and a label. That effectively negates the presumed simplicity of the API.

It is also questionable whether the assumption that most Event Groups will carry some sensitive data is valid; presumably, many developers may want their Event Groups to represent code structures (i.e. similar to function names), or to be based on non-sensitive data (i.e. an index number or non-sensitive business identifiers).

For those reasons, it was later decided to make the ID the primary, mandatory argument of createEventGroup(...), and the label the optional second argument. Labels are still serialized as json/plain payloads then codec-encoded, but they are no longer used as the grouping key, neither directly nor indirectly through the ID derivation formula.

When a label is omitted, the Marker carries only the ID, which the UI should display in replacement of the label payload.

Change Log

2026-09-14

  • Reversed createEventGroup(...) arguments so the ID is now required, and the label is optional. The reference factory is now defined as createEventGroup(id, options?: { label?: string }). The SDK uses the ID verbatim; it no longer derives IDs as SHA1(runId + label). An omitted label emits no payload. A provided label is still json/plain via the default converter, then the worker's payload codec if any. User-provided IDs are not hashed. See Why Event Group IDs are caller-provided and plaintext.
  • Removed the InitializeWorkflow.original_execution_run_id field from this proposal. This field was previously used as a salt prefix as part of the Label ID derivation formula. Since that formula is now removed, this field is no longer needed. Note that even though this proposal no longer requires the field, that field has already been added to the Core SDK's InitializeWorkflow message, and there is no intent to actually remove it at this point.

2026-08-19

  • Removed the implicit Event Group around the Workflow’s main function (i.e. associated with the Workflow Execution Started event). That implicit group adds a few bytes to most events in history with no real value — that association can easily be inferred from the fact that an event does not carry an implicit signal handler or update handler Event Group Marker. Also removed the associated Protobuf field InitializeWorkflow.originating_event_id, which was required only to allow lang side to safely produce an implicit Event Group Marker linking back to the WORKFLOW_EXECUTION_STARTED event.
  • Specify that Event Group’s Label must be converted to Payload using the SDK’s Default Payload Converters, rather than user-provided Converters. The initial proposal was to use the user-provided Payload Converters, for alignment with the User Metadata feature, which mandates the use of user-provided Payload Converters. There’s however no real justification for either Event Groups nor User Metadata to rely on user-provided converters, yet this presents risks that the user-provided converters might encode strings into something that is not proper json/plain, thus breaking the feature on the UI side.

Event Groups — Test Cases

Reference list of behaviors that the Event Groups feature must be tested for, in every SDK. This file is the source of truth for test coverage; individual SDKs may need extra language-specific tests, but everything listed here should be covered somewhere.

All code snippets are pseudo-code.

Test methodology notes

Assertion source

Prefer asserting against the persisted history: the server transcribes a command's event_group_markers verbatim onto the resulting HistoryEvent, so history assertions cover the SDK, Core (where applicable) and the server in one shot. Fall back to asserting on outgoing commands only where history is not observable, or for negative cases about commands that never reach the server.

Unspecified behavior

The following behaviors are deliberately unspecified.

Marker order — event_group_markers is a set of unique markers that happens to be serialized as a protobuf repeated field. The order in which implicit, scope-derived and directly attached markers appear on a command carries no meaning, and no SDK is required to reproduce another's. Always compare marker collections as unordered sets — never with a positional deep-equality assertion on the array. Also confirm that the number of items in the stored set matches expectations.

Label payload on an ID collision — When groups sharing an ID carry different labels and are introduced in the same command, which label ends up on the wire is unspecified. Group identity is defined by ID alone, so tests touching this situation must compare marker ID sets rather than whole markers.

Retracted cases — Cases marked Retracted are kept so prior IDs stay reviewable. Do not implement them, and prune existing tests that are still referencing them. The test plan cases can be pruned once every implementations have been updated to the new behavior.

Notes vs Divergences

Note is cross-SDK rationale and setup constraints. Omit when there is nothing to say.

Divergences is per-SDK skips or API-shape differences. One bullet per SDK or SDK family. Omit when every SDK can follow Execute as written. Do not restate that an SDK matches the snippet.


1. Explicit Event Groups Marker Label IDs (EG-LABEL-ID)

Derived IDs

Retracted: As of 2026-09-14, IDs are now always user-provided, and there is no longer a concept of ID derivation. All tests in this subsection are retracted.

Assertions in this subsection assume the following workflow:

  let a = createEventGroup('aaa')

  // b1 and b2 have the same label, without user-provided id => same group
  let b1 = createEventGroup('bbb')
  let b2 = createEventGroup('bbb')

  // One activity call for each label object
  executeActivity("activity-a", { eventGroups: [ a ] })
  executeActivity("activity-b1", { eventGroups: [ b1 ] })
  executeActivity("activity-b2", { eventGroups: [ b2 ] })

EG-LABEL-ID-00 — Derived IDs match the specified formula [RETRACTED]

Marker IDs in history match the expected value derived from the workflow's original execution run id and the label 'aaa', using the formula:

  defaultMarkerIdForLabel(workflow, label) =
    lowercase(hex(sha1(workflow.original_execution_run_id + label)))

Expect:

  markerIds(W['activity-a']) == { defaultMarkerIdForLabel(W, 'aaa') }

EG-LABEL-ID-01 — Same labels + no user-provided ID + same workflow exec => same group IDs [RETRACTED]

Expect:

  markerIds(W['activity-b1']) == markerIds(W['activity-b2'])

EG-LABEL-ID-02 — Different labels + no user-provided ID + same workflow exec => distinct group IDs [RETRACTED]

Expect:

  markerIds(W['activity-a']) != markerIds(W['activity-b1'])

EG-LABEL-ID-03 — Same labels + no user-provided ID + different workflow execs => distinct group IDs [RETRACTED]

Setup: Run the above workflow twice, as W1 and W2.

Expect:

  markerIds(W1['activity-a']) != markerIds(W2['activity-a'])

EG-LABEL-ID-04 — Derived IDs are stable across a workflow reset [RETRACTED]

Setup:

  1. Run the above workflow to completion, as W.
  2. Capture W's execution history (i.e. before reset).
  3. Reset W at its first WFTStarted event and let it run to completion, as W_reset.

Expect:

  markerIds(W['activity-a']) == markerIds(W_reset['activity-a'])

User-provided IDs

Assertions in this subsection assume the following workflow:

  let c = createEventGroup('c-id', { label: 'ccc' })

  // d1 and d2 have different labels but the same ID => same group
  let d1 = createEventGroup('d-id', { label: 'ddd1' })
  let d2 = createEventGroup('d-id', { label: 'ddd2' })

  // Same label as c, but different ID => distinct group
  let notC = createEventGroup('not-c-id', { label: 'ccc' })

  // One activity call for each label object
  executeActivity("activity-c", { eventGroups: [ c ] })
  executeActivity("activity-d1", { eventGroups: [ d1 ] })
  executeActivity("activity-d2", { eventGroups: [ d2 ] })
  executeActivity("activity-notC", { eventGroups: [ notC ] })

EG-LABEL-ID-20 — IDs are used verbatim

Expect:

  markerIds(W['activity-c']) == { 'c-id' }

EG-LABEL-ID-21 — Different labels + same ID => same group

Expect:

  markerIds(W['activity-d1']) == markerIds(W['activity-d2'])

EG-LABEL-ID-22 — Same label + different IDs => distinct groups

Expect:

  markerIds(W['activity-c']) != markerIds(W['activity-notC'])

2. Explicit Event Groups Marker Label Payload (EG-LABEL-PAYLOAD)

All assertions in this section assume the following workflow:

  let a = createEventGroup('aaa')
  let b = createEventGroup('bbb', { label: 'Label B' })
  
  executeActivity("activity-a", { eventGroups: [ a ] })
  executeActivity("activity-b", { eventGroups: [ b ] })

Each grouped activity has exactly one marker.

An omitted label means the Marker's label payload field is unset.

The expected default label payload is:

  defaultLabelPayload(value) = {
      encoding: 'json/plain',
      data: jsonEncode(value)
  }

Label Payload

EG-LABEL-PAYLOAD-00 — Label Payload converts to a json/plain JSON string

Expect:

  labelPayload(W['activity-b']) == defaultLabelPayload('Label B')

EG-LABEL-PAYLOAD-01 — Label Payload goes through the SDK's Default Payload Converter

Setup: Use a worker with no payload codec, and a custom payload converter that encodes strings with encoding custom and prepends custom-converter- to their data.

Expect:

  labelPayload(W['activity-b']) == defaultLabelPayload('Label B')

EG-LABEL-PAYLOAD-02 — An omitted label produces no payload

Expect:

  labelPayload(W['activity-a']) is unset

Label Payload Codecs

EG-LABEL-PAYLOAD-20 — Label Payload is codec-encoded

Setup: Use a worker with a payload codec represented below as codecEncode, which performs a reversible byte transformation on payload data.

Expect:

  labelPayload(W['activity-b']) == codecEncode(defaultLabelPayload('Label B'))

EG-LABEL-PAYLOAD-21 — Label ID is not codec-encoded

Setup: Use the same payload codec as EG-LABEL-PAYLOAD-20.

Expect:

  markerIds(W['activity-a']) == { 'aaa' }
  markerIds(W['activity-b']) == { 'bbb' }

3. Explicit Event Group Scopes (EG-SCOPE)

Each case brings its own workflow.

EG-SCOPE-00 — Commands in an Event Group scope carry its marker

Execute:

  let a = createEventGroup('aaa')

  withScope(a):
      executeActivity("activity")           // Expect: { a }
      startTimer("timer")                   // Expect: { a }
      startChildWorkflow("child")           // Expect: { a }

Note: This is only a baseline test. Full per-command type coverage lives in EG-COMMANDS.

EG-SCOPE-01 — Nesting Event Group Scopes composes correctly

Execute:

  let a..b = createEventGroup('aaa'..'bbb')

  withScope(a):
      executeActivity("a-before")           // Expect: { a }
      withScope(b):
          executeActivity("a-b")            // Expect: { a, b }
      executeActivity("a-after")            // Expect: { a }
  executeActivity("outside")                // Expect: {}

EG-SCOPE-02 — Re-entering an Event Group instance nests correctly

Execute:

  let a = createEventGroup('aaa')

  withScope(a):
      executeActivity("a-before")           // Expect: { a }
      withScope(a):
          executeActivity("a-inner")        // Expect: { a }
      executeActivity("a-after")            // Expect: { a }
  executeActivity("outside")                // Expect: {}

EG-SCOPE-03 — An Event Group instance can be scoped concurrently from two branches

Execute:

  let a..e = createEventGroup('aaa'..'eee')

  let left = [async]:
      withScope(b):
          withScope(a):
              withScope(c):
                  executeActivity("b-a-c")  // Expect: { b, a, c }
              executeActivity("b-a")        // Expect: { b, a }
          executeActivity("b-after-a")      // Expect: { b }

  let right = [async]:
      withScope(d):
          withScope(a):
              withScope(e):
                  executeActivity("d-a-e")  // Expect: { d, a, e }
              executeActivity("d-a")        // Expect: { d, a }
          executeActivity("d-after-a")      // Expect: { d }

  await all [left, right]

  executeActivity("outside")                // Expect: {}

EG-SCOPE-04 — A task started inside a scope keeps it after the scope exits

Execute:

  let a = createEventGroup('aaa')

  let task
  withScope(a):
      task = [async]:
          executeActivity("inside-before")  // Expect: { a }
          [continue after the enclosing withScope(a) exits]
          executeActivity("inside-after")   // Expect: { a }

  await [task]
  
  executeActivity("outside")                // Expect: {}

EG-SCOPE-05 — A task created outside a scope does not inherit it when resumed inside

Execute:

  let a = createEventGroup('aaa')

  let task = [async]:
      [continue after the parent enters withScope(a)]
      executeActivity("outside-task")       // Expect: {}

  withScope(a):
      await [task]

EG-SCOPE-06 — An Event Group scope unwinds cleanly when its body throws

Execute:

  let a..b = createEventGroup('aaa'..'bbb')

  withScope(a):
      try:
          withScope(b):
              executeActivity("a-b")        // Expect: { a, b }
              raise Error('boom')
      catch:
          pass

      executeActivity("a-after")            // Expect: { a }

4. Implicit Event Groups (EG-IMPLICIT)

Highlights:

  • Implicit Event Groups are automatically created around each inbound signal and accepted update being handled.
  • A signal's implicit marker is implicit(S), an inbound_event marker whose ID equals the corresponding WORKFLOW_EXECUTION_SIGNALED history event ID.
  • An update's implicit marker is implicit(U), an inbound_update marker whose ID equals the update ID.
  • A handler's implicit scope replaces any ambient scope captured during runtime registration. Explicit scopes entered inside the handler compose on top of the implicit scope.

Unless shown otherwise, the Workflow main function only waits for the invoked handlers to complete and is omitted from the snippets. When a test uses a runtime-registered update handler, invoke the update only after registration is complete.

Signal handlers

EG-IMPLICIT-00 — A statically declared signal handler carries the signaled event's marker

Execute:

  on signal 'mySignal' (S):               // static signal handler
      executeActivity("from-static-signal")      // Expect: { implicit(S) }

  workflow main function:
      [wait until signal 'mySignal' is received and fully handled]

Divergences:

  • TypeScript, Go: skip — no statically declared signal handlers.

EG-IMPLICIT-01 — An explicit scope composes with a statically declared signal handler's implicit scope

Execute:

  on signal 'mySignal' (S):               // static signal handler
      let a = createEventGroup('aaa')
      withScope(a):
          executeActivity("from-static-signal")  // Expect: { implicit(S), a }

  workflow main function:
      [wait until signal 'mySignal' is received and fully handled]

Divergences:

  • TypeScript, Go: skip — no statically declared signal handlers.

EG-IMPLICIT-10 — A runtime-registered signal handler does not inherit its registration scope

Execute:

  workflow main function:
      let outside = createEventGroup('outside')

      withScope(outside):
          on signal 'mySignal' (S):                   // runtime-registered signal handler
              executeActivity("from-runtime-signal")  // Expect: { implicit(S) }

      [wait until signal 'mySignal' is received and fully handled]

Divergences:

  • Go, Rust: skip — no runtime-registered signal handlers.

EG-IMPLICIT-11 — An explicit scope composes with a runtime-registered signal handler's implicit scope

Execute:

  workflow main function:
      let inside = createEventGroup('inside')

      on signal 'mySignal' (S):                   // runtime-registered signal handler
          withScope(inside):
              executeActivity("from-runtime-signal")  // Expect: { implicit(S), inside }

      [wait until signal 'mySignal' is received and fully handled]

Divergences:

  • Go, Rust: skip — no runtime-registered signal handlers.

EG-IMPLICIT-12 — A signal buffered before runtime registration keeps its original implicit marker

Execute:

  • Start the following workflow:
  workflow main function:
      [block until signal 'unblock' is received]

      on signal 'mySignal' (S):                   // runtime-registered signal handler
          executeActivity("from-runtime-signal")  // Expect: { implicit(S) }

      [wait until signal 'mySignal' is received and fully handled]
  • Send signal mySignal to the workflow, then signal unblock.

Divergences:

  • Go, Rust: skip — no runtime-registered signal handlers.

EG-IMPLICIT-20 — A catch-all signal handler carries the signaled event's marker

Execute:

  • Start the following workflow:
  on signal '*' (S):                            // Catch-all signal handler - either statically declared or runtime-registered
      executeActivity("from-catch-all-signal")  // Expect: { implicit(S) }

  [wait until signal 'non-existent-signal' is received and fully handled]
  • Send signal non-existent-signal to the workflow.

Note:

  • The catch-all signal handler in this test case may be statically declared or registered at runtime — it is not pertinent to assert both cases.

Divergences:

  • Go, Rust: skip — no catch-all signal handlers.

EG-IMPLICIT-30 — Signal implicit scope does not leak to commands in the Workflow main function

Execute:

  • Start the following workflow:
  on signal 'mySignal' (S):                         // Either statically declared or runtime-registered signal handler
      executeActivity("from-signal")                // Expect: { implicit(S) }

  executeActivity("from-main-before-signal")        // Expect: {}
  [wait until signal 'mySignal' is received and fully handled]
  executeActivity("from-main-after-signal")         // Expect: {}
  • Send signal mySignal to the workflow.

Divergences:

  • Go: TBD — behavior remains undecided.

Update handlers

EG-IMPLICIT-50 — A statically declared update handler carries the update ID

Execute:

  • Start the following workflow:
  on update 'myUpdate' (U):                   // static update handler
      executeActivity("from-static-update")  // Expect: { implicit(U) }

  workflow main function:
      [wait until update 'myUpdate' is received and fully handled]
  • Invoke update myUpdate.

Divergences:

  • TypeScript, Go: skip — no statically declared update handlers.

EG-IMPLICIT-51 — An explicit scope composes with a statically declared update handler's implicit scope

Execute:

  • Start the following workflow:
  on update 'myUpdate' (U):                   // static update handler
      let inside = createEventGroup('inside')
      withScope(inside):
          executeActivity("from-static-update")  // Expect: { implicit(U), inside }

  workflow main function:
      [wait until update 'myUpdate' is received and fully handled]
  • Invoke update myUpdate.

Divergences:

  • TypeScript, Go: skip — no statically declared update handlers.

EG-IMPLICIT-60 — A runtime-registered update handler does not inherit its registration scope

Execute:

  • Start the following workflow:
  workflow main function:
      let outside = createEventGroup('outside')

      withScope(outside):
          on update 'myUpdate' (U):                   // runtime-registered update handler
              executeActivity("from-runtime-update")  // Expect: { implicit(U) }

      [wait until update 'myUpdate' is received and fully handled]
  • Invoke update myUpdate.

Divergences:

  • Rust: skip — no runtime-registered update handlers.

EG-IMPLICIT-61 — An explicit scope composes with a runtime-registered update handler's implicit scope

Execute:

  • Start the following workflow:
  workflow main function:
      let inside = createEventGroup('inside')

      on update 'myUpdate' (U):                   // runtime-registered update handler
          withScope(inside):
              executeActivity("from-runtime-update")  // Expect: { implicit(U), inside }

      [wait until update 'myUpdate' is received and fully handled]
  • Invoke update myUpdate.

Divergences:

  • Rust: skip — no runtime-registered update handlers.

EG-IMPLICIT-70 — A catch-all update handler carries the update ID

Execute:

  • Start the following workflow:
  on update '*' (U):                            // Catch-all update handler - either statically declared or runtime-registered
      executeActivity("from-catch-all-update")  // Expect: { implicit(U) }

  [wait until update 'non-existent-update' is received and fully handled]
  • Invoke update non-existent-update.

Note:

  • The catch-all update handler in this test case may be statically declared or registered at runtime — it is not pertinent to assert both cases.

Divergences:

  • Go, Rust: skip — no catch-all update handlers.

EG-IMPLICIT-80 — Update implicit scope does not leak to commands in the Workflow main function

Execute:

  • Start the following workflow:
  on update 'myUpdate' (U):                         // Either statically declared or runtime-registered update handler
      executeActivity("from-update")                // Expect: { implicit(U) }

  executeActivity("from-main-before-update")        // Expect: {}
  [wait until update 'myUpdate' is received and fully handled]
  executeActivity("from-main-after-update")         // Expect: {}
  • Invoke update myUpdate.

Divergences:

  • Rust: skip — no update handlers.

5. Event Group Marker Aggregation (EG-AGGREGATION)

Highlights:

  • Directly attached markers and markers from active scopes are aggregated into one set.
  • Label markers sharing an ID collapse into one regardless of how they were introduced.

All test cases in this section use the following Event Groups:

  // Same ID, no label => one group, and so one marker
  let a1 = createEventGroup('aaa')
  let a2 = createEventGroup('aaa')

  // Same ID, different labels => also one group, and so also one marker
  let b1 = createEventGroup('b-id', { label: 'bbb1' })
  let b2 = createEventGroup('b-id', { label: 'bbb2' })

EG-AGGREGATION-00 — Duplicate markers directly attached to one command collapse into a set

Execute:

  executeActivity(
      "direct-duplicates",
      { eventGroups: [ a2, b1, a1, b1, a2, a1 ] },      // Expect: { a1, b1 }
  )

EG-AGGREGATION-01 — Nested scopes of duplicate groups collapse into a set

Execute:

  withScope(a1):
      withScope(a2):
          withScope(b1):
              executeActivity("nested-scopes")          // Expect: { a1, b1 }

EG-AGGREGATION-02 — Scoped and directly attached markers collapse into one set

Execute:

  withScope(a1):
      withScope(b1):
          executeActivity(
              "scope-and-direct-b",
              { eventGroups: [ b1 ] },                  // Expect: { a1, b1 }
          )
          executeActivity(
              "scope-and-direct-a-b",
              { eventGroups: [ b1, a1 ] },              // Expect: { a1, b1 }
          )

EG-AGGREGATION-03 — The same group instance listed twice contributes one marker

Execute:

  executeActivity(
      "same-instance-twice",
      { eventGroups: [ a1, a1 ] },                      // Expect: { a1 }
  )

EG-AGGREGATION-04 — Aggregation keys label markers by ID, disregarding labels

Execute:

  executeActivity(
      "same-id-direct",
      { eventGroups: [ b1, b2 ] },                      // Expect: { b1 }
  )

  withScope(b1):
      executeActivity(
          "same-id-scope-and-direct",
          { eventGroups: [ b2 ] },                      // Expect: { b1 }
      )

6. Command Type Coverage (EG-COMMANDS)

Highlights:

  • Every command-producing API must carry markers from the active Event Group scopes.
  • Every command-producing API that accepts options must also accept directly attached markers.
  • Cover commands produced internally without obvious user-facing involvement, including wait-condition timers, Local Activity markers and backoff timers, cancellation commands, the search-attribute upsert accompanying a patch command, etc.
  • Each SDK must adapt these cases to the APIs it exposes for each command family.

Each case assumes the following Event Groups:

  let direct = createEventGroup('direct')
  let scope = createEventGroup('scope')

Unless otherwise specified, the case's Execute snippet runs inside withScope(scope):. Cancellation commands belong to the family of the command they cancel.

Regular asynchronous commands

EG-COMMANDS-00 — Timer commands carry markers

Execute:

  startTimer('1ms', eventGroups: [ direct ] )          // Expect: { direct, scope }

EG-COMMANDS-00-CANCEL — Timer Cancellation commands carry Timer's markers

Execute:

  withTimeout('1ms'):
      startTimer('60s', eventGroups: [ direct ] )
      [ignore the Timer's cancellation error]

Expect:

  • The TimerStarted event for the 1ms timeout carries { scope } only.
  • The TimerStarted event for the 60s timer carries { direct, scope }.
  • The TimerCanceled event carries { direct, scope }.

EG-COMMANDS-01 — Wait conditions with timeouts carry markers to their timers

Execute:

  waitCondition(
      fn: () => false,
      timeout: '1ms',
      eventGroups: [ direct ],                 // Expect: { direct, scope }
  )  

Note: waitCondition without a timeout produces no command, so there is nothing to assert on it.

EG-COMMANDS-02 — Activity commands carry markers

Execute:

  executeActivity(
      type: "activity",
      scheduleToStartTimeout: '10s',
      eventGroups: [ direct ],                // Expect: { direct, scope }
  )

EG-COMMANDS-02-CANCEL — Activity Cancellation commands carry Activity's markers

Execute:

  withTimeout('1ms'):
      executeActivity(
          type: "activity-cancelled-sleep-5s",        // Activity must sleep for 5s+
          scheduleToStartTimeout: '10s',
          cancellationType: "TRY_CANCEL",
          eventGroups: [ direct ],
      )
      [ignore the Activity's cancellation error]

Expect:

  • The ActivityTaskScheduled event carries { direct, scope }.
  • The ActivityTaskCancelRequested event carries { direct, scope }.

Note: The "activity-cancelled-sleep-5s" activity must sleep for at least 5s to ensure it does not complete normally before the cancellation command is issued.

EG-COMMANDS-03 — Local Activity commands carry markers

Execute:

  executeLocalActivity(
      type: "local-activity",
      eventGroups: [ direct ],            // Expect: { direct, scope }
  )

EG-COMMANDS-03-CANCEL — Local Activity Cancellation commands carry LA's markers

Execute:

  let cancellation = cancellationScope()
  let cancelTriggerAsync, cancelledAsync

  withCancellation(cancellation):
      cancelTriggerAsync = [async]:
          executeLocalActivity(
              type: "cancel-trigger",
              eventGroups: [ direct, createEventGroup('cancel-trigger') ],
          )
          cancel(cancellation)

      cancelledAsync = [async]:
          executeLocalActivity(
              type: "cancelled-local-activity-sleep-5s",    // Activity must sleep for 5s+
              eventGroups: [ direct, createEventGroup('cancelled-la') ],
          )
          [ignore the LA's cancellation error]

  await all [cancelTriggerAsync, cancelledAsync]

Expect:

  • The MarkerRecorded event for cancel-trigger carries { direct, scope, cancel-trigger }.
  • The MarkerRecorded event for cancelled-local-activity-sleep-5s (indicating cancellation of the LA) carries { direct, scope, cancelled-la }.

Note: The "cancelled-local-activity-sleep-5s" activity must sleep for at least 5s to ensure it does not complete normally before the cancellation command has been issued.

EG-COMMANDS-03-BACKOFF — Local Activity Retry Backoff Timer carries LA's markers

Execute:

  • Start the following workflow with workflowTaskTimeout of 5s.
  executeLocalActivity(
      type: "backoff-local-activity-fail-first-attempt",          // Activity must fail on first attempt with a retryable error
      retryPolicy: {
          // Longer than WorkflowTaskTimeout, forcing an SDK backoff timer.
          initialInterval: '10s',
          maximumAttempts: 2,
      },
      eventGroups: [ direct ],
  )

Expect:

  • The first MarkerRecorded event (indicating LA backoff) carries { direct, scope }.
  • The TimerStarted event (for backoff) carries { direct, scope }.
  • The MarkerRecorded event (for LA completion) carries { direct, scope }.

Note: The "backoff-local-activity-fail-first-attempt" activity must fail on its first attempt with a retryable error to ensure it is retried with a backoff timer by the Local Activity's state machine.

EG-COMMANDS-04 — Child Workflow commands carry markers

Execute:

  startChildWorkflow(
      type: "child-workflow",
      eventGroups: [ direct ],                          // Expect: { direct, scope }
  )

EG-COMMANDS-04-CANCEL — Child Workflow Cancellation commands carry the Child Workflow's markers

Execute:

  withTimeout('1ms'):
      startChildWorkflow(
          type: "child-workflow-sleep-5s",             // Child Workflow must sleep for 5s+
          eventGroups: [ direct ],
          cancellationType: "WAIT_CANCELLATION_REQUESTED",
      )
      [ignore the Child Workflow's cancellation error]

Expect:

  • The StartChildWorkflowExecutionInitiated event carries { direct, scope }.
  • The RequestCancelExternalWorkflowExecutionInitiated event carries { direct, scope }.

Note: The child workflow must sleep for at least 5s to ensure it does not complete normally before the cancellation command has been issued.

EG-COMMANDS-05 — Nexus Operation commands carry markers

Execute:

  executeNexusOperation(
      type: "nexus-operation",
      eventGroups: [ direct ],                        // Expect: { direct, scope }
  )

Divergences:

  • SDKs without Nexus: skip.

EG-COMMANDS-05-CANCEL — Nexus Operation Cancellation commands carry the Nexus Operation's markers

Execute:

  withTimeout('1ms'):
      executeNexusOperation(
          type: "nexus-operation-sleep-5s",        // Nexus Operation must sleep for 5s+
          cancellationType: "WAIT_CANCELLATION_REQUESTED",
          eventGroups: [ direct ],
      )
      [ignore the Nexus Operation's cancellation error]

Expect:

  • The NexusOperationScheduled event carries { direct, scope }.
  • The NexusOperationCancelRequested event carries { direct, scope }.

Note:

  • The Nexus Operation must sleep for at least 5s to ensure it does not complete normally before the cancellation command has been issued.

Divergences:

  • SDKs without Nexus: skip.

EG-COMMANDS-06 — Signal External Workflow commands carry markers

Execute:

  let otherWorkflow = getExternalWorkflowHandle("other-workflow")
  signalExternalWorkflow(
      otherWorkflow,
      signalName: "signal",
      eventGroups: [ direct ],              // Expect: { direct, scope }
  )
  [ignore the Workflow Not Found error]

Note:

  • Targeting a missing Workflow keeps the case self-contained because the server records the Initiated event before failing the request.

Divergences:

  • TypeScript: signal(...) is variadic and cannot take options, so signal() asserts ambient scope only. signalWithOptions() is the direct-attach path. Assert both.

EG-COMMANDS-06-CHILD — Signal Child Workflow commands carry markers

Execute:

  // Start the child workflow without the ambient "scope" Event Group
  let child = startChildWorkflow(type: "child-workflow-wait-for-signals")  

  // Issue the signal with direct-attach Event Groups
  withScope(scope):
    child.signal("signal", eventGroups: [ direct ])             // Expect: { direct, scope }

Note:

  • Same command type as EG-COMMANDS-06, issued through the child-workflow handle. SDKs implement that handle separately from getExternalWorkflowHandle, so a missing eventGroups plumb on one path would not be caught by EG-COMMANDS-06.
  • The child must wait for both signals so it does not complete before they are issued.

Divergences:

  • TypeScript: signal(...) is variadic and cannot take options, so signal() asserts ambient scope only. signalWithOptions() is the direct-attach path. Assert both.

EG-COMMANDS-07 — Cancel External Workflow commands carry markers

Execute:

  let otherWorkflow = getExternalWorkflowHandle("other-workflow")
  cancelExternalWorkflow(
      otherWorkflow,
      eventGroups: [ direct ],              // Expect: { direct, scope }
  )
  [ignore the Workflow Not Found error]

Note:

  • This case is different from EG-COMMANDS-04 because it covers the case where cancel() is invoked as a first-class command and may therefore receive cancellation specific options.
  • Targeting a missing Workflow keeps the case self-contained because the server records the Initiated event before failing the request.

EG-COMMANDS-07-CHILD — Cancel Child Workflow commands carry markers

Execute:

  // Start the child workflow without the ambient "scope" Event Group
  let child = startChildWorkflow(type: "child-workflow-sleep-5s")

  // Issue the cancel with direct-attach Event Groups
  withScope(scope):
    child.cancel(eventGroups: [ direct ])              // Expect: { direct, scope }

Note:

  • Same idea as EG-COMMANDS-06-CHILD: first-class cancel() through the child-workflow handle, not getExternalWorkflowHandle. Distinct from EG-COMMANDS-04-CANCEL, which inherits markers from the start-child command rather than attaching them at the cancel call site.
  • The child must sleep for at least 5s so it does not complete before the cancel is issued.

Divergences:

  • TypeScript: skip — ChildWorkflowHandle has no cancel() (sdk-typescript#740). Do not add that API as part of Event Groups.
  • Python: skip — child handle is an asyncio.Task; Task.cancel() does not take event_groups and is the inherited-from-start path (EG-COMMANDS-04-CANCEL).

Synchronous and Workflow state/metadata mutation commands

EG-COMMANDS-20 — Modify Workflow Properties commands carry markers

Execute:

  upsertMemo(                               // Expect `WorkflowPropertiesModified`: { direct, scope }
    "some-key": "some-value",
    eventGroups: [ direct ],
  )

Note:

  • The upsertMemo API is currently the only action on the ModifyWorkflow command. If other actions are eventually added to that command, they should be added to this test case.

EG-COMMANDS-21 — Upsert Search Attribute commands carry markers

Execute:

  upsertSearchAttributes(                   // Expect: `UpsertWorkflowSearchAttributes`: { direct, scope }
    { someAttribute: "some-value" },
    eventGroups: [ direct ],
  )

Note:

  • Replace someAttribute with a search attribute key that exists on the namespace.

EG-COMMANDS-22 — Patch commands carry markers

Execute:

  patched("my-patch-1", eventGroups: [ direct ])
  deprecatePatch("my-patch-2", eventGroups: [ direct ])

Expect:

  • Both MarkerRecorded events carry { direct, scope }.
  • Both UpsertWorkflowSearchAttributes events for TemporalChangeVersion carry { direct, scope }.

Note:

  • The TemporalChangeVersion upsert synthesized beside a patch marker inherits the patch's annotations.

Divergences:

  • Go, Java, PHP: skip — no patched / deprecatePatch.

EG-COMMANDS-23 — Version commands carry the ambient scope

TBD

Divergences:

  • Core-based SDKs: skip — no version API.

EG-COMMANDS-24 — Side Effect commands carry the ambient scope

TBD

Divergences:

  • Core-based SDKs: skip — no sideEffect / mutableSideEffect.

Terminal Commands

Note:

  • At this time, the CompleteWorkflowExecution, CancelWorkflowExecution and FailWorkflowExecution terminal commands can't receive Event Groups, neither through ambiant scopes nor directly attached, since a) there is no user-visible completion function that they call, to which the Event Groups could be passed as option, and b) the command itself happens outside of any user-provided function scope, and therefore outside of any explicit Event Group scope.
  • The TerminateWorkflowExecution terminal command will never receive Event Groups, since that command is never produced by the SDK.

EG-COMMANDS-40 — Continue-As-New carries directly attached and ambient markers

Execute:

  function continueAsNewWorkflow(secondRun = false):
      // Avoid infinite recursion
      if secondRun:
          return
  
      continueAsNew(
          args: [true],
          eventGroups: [ direct ],                   // Expect: { direct, scope }
      )

Divergences:

  • TypeScript: continueAsNew() has a short form that does not accept options; that path carries ambient markers only. Assert both the short and long forms.

####################################################################################################

TODO ITEMS

####################################################################################################

7. Event Group Payload Deduplication (EG-PAYDEDUP)

  • Marker seen for the first time on multiple commands of a single WFT => only one payload is emitted
  • Marker seen previously on a completed WFT => payload is not reemitted
  • Marker seen previously on an inbound WFT but never completed => payload is reemitted (this implies workflow code modification)
  • Marker attached to LAs that resolve out of order / WFT heartbeat => payload is emitted exactly once, on the very first command that produces it

Follow-ups

  • When a cancel API accepts eventGroups, lang-supplied markers should override those inherited from the command being cancelled (EG-COMMANDS-*-CANCEL). Add coverage once at least one SDK exposes that option.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment