Documentation / Foundations

Message metadata

A message carries two kinds of information: the domain payload says what the application is doing, and its Metadata says how that work is related to a caller and other messages. For example, RequestRegistration contains a workshop ID and display name. Its headers carry the user, device, and identifiers used to follow the request.

On this page

The distinction matters when a request crosses actors or runs again after a failure. A registration ID is a business identity. A correlation ID groups related work. A message ID identifies a message in that work. They answer different questions and should be stored and logged separately.

The six fields

Field Question it answers Typical lifetime
CorrelationId Which user interaction or workflow does this belong to? Shared by related work in one chosen correlation scope.
MessageId Which message or runtime dispatch is this? Distinguishes messages within a correlation.
CausationId Which message directly caused this message? Parent link; normally absent at a root boundary.
UserId Which user is attributed to this action? Preserved as attribution when appropriate; verified at ingress.
DeviceId Which client device/session context originated the action? Shared across requests from that verified device context.
TransactionId Which Troolio transaction context does this dispatch participate in? Set by the command protocol; not chosen freely by a browser.

All six are Guid values; CausationId and TransactionId are nullable. The three-argument Metadata constructor creates a message ID for you. It does not prove that a user, device, or correlation scope is valid.

Construct headers at the boundary

The application authenticates the request and obtains the current user and device from its trusted session. It then constructs headers for a command. Keep the same correlation ID across the related work you want to investigate together.

public static Metadata AtIngress(Guid correlationId, Guid userId, Guid deviceId) =>
    new(correlationId, userId, deviceId);

A client-supplied correlation ID can be useful for support, but validate its shape and decide whether to accept it or allocate a server value. An arbitrary client payload must not choose the effective UserId simply by setting a header. Likewise, possession of someone else's DeviceId is not a credential.

The workshop sample sends those headers with a typed command to the registration actor:

public static Task RequestAsync(ITroolioClient client, Guid registrationId, Guid workshopId,
    string displayName, Guid authenticatedUserId, Guid verifiedDeviceId, Guid correlationId)
{
    Metadata headers = MessageContext.AtIngress(correlationId, authenticatedUserId, verifiedDeviceId);
    return client.Tell<IRegistrationActor>(registrationId.ToString("D"),
        new RequestRegistration(headers, registrationId, workshopId, displayName));
}

Use the registration ID as the actor key and business retry identity. It remains meaningful even if diagnostic message IDs change along the dispatch path.

What ordinary Troolio dispatch does

In the ordinary 10.0.8 command path, Troolio establishes a transaction ID and replaces the incoming command's message ID for runtime handling. When it prepares events, it creates event message IDs and sets their causation to the handled command's ID. An ordinary InternalCommand receives its own runtime message ID and takes causation from the message ID carried in its incoming headers.

That is why an orchestration listener forwards the triggering event's headers to an internal command. Let the command protocol establish the next step. The selected atomic path uses its verified operation identity and contextual rules; follow the selected route when using that path.

Trace-enabled projection and orchestration processing may also derive delivery headers. Therefore a subscriber should use the provider's source identity, such as stream and version, for durable deduplication. A diagnostic message ID is not a universal projection receipt key.

User, owner, and recipient are different roles

Imagine an administrator cancels a registration for an attendee. The acting user is the administrator; the registration's owner and confirmation recipient may be the attendee. Store those domain roles explicitly. Replacing UserId with the recipient destroys useful attribution and can obscure who authorized the operation.

Background work also needs an explicit policy. Retaining an initiating user's ID explains attribution, while current authorization still has to be checked before accessing data or changing state. A system actor is not automatically privileged because a header contains Guid.Empty.

Transaction context and later jobs

An external command should enter with no caller-invented transaction ID. Internal work can carry the active context through orchestration. A scheduled job that runs much later starts a new transaction scope when it issues its own external command; it should not keep a completed transaction alive through old headers.

public static Metadata Scheduled(Metadata trigger, Guid currentUserId) =>
    new(Guid.NewGuid(), currentUserId, Guid.Empty)
    {
        CausationId = trigger.MessageId,
        TransactionId = null
    };

This is an application policy for a later job, including a new correlation scope and a causal link to its trigger. Recheck the relevant authority when the job runs. Use a durable job or operation ID for retries.

Continue with correlation and causation, actor identity, and observability. The workshop sample defines the application helpers shown here.