Cascade Context & Security Propagation¶
Verified by tests
CascadeContextTests, CascadeContextFactoryTests, SecurityContextTests, MessageContextTests, ScopeContextAccessorTests, ScopeContextAccessorInitiatingContextTests, ScopedMessageContextTests, SecurityContextHelperTests, OutboxCascadeIdentityPersistenceIntegrationTests — library CI run #31657041675 (2026-08-13)
When a message produces child messages, CascadeContext carries everything those children need: correlation tracking, causation chains, security identity, and custom metadata. It is the single transfer object for context propagation, replacing manual extraction of individual properties.
How It Fits Together¶
graph TB
IE["Incoming Envelope"]
F["CascadeContextFactory<br/>.FromEnvelope()"]
CC["CascadeContext<br/>CorrelationId<br/>CausationId<br/>SecurityContext<br/>Metadata"]
EN["ICascadeContextEnricher (1..N)"]
EC["Enriched CascadeContext"]
MC["MessageContext.Create(cascade)"]
CE["Child Message Envelope"]
IE --> F --> CC --> EN --> EC --> MC --> CE
style IE fill:#fff3cd,stroke:#ffc107
style CE fill:#fff3cd,stroke:#ffc107
style CC fill:#d4edda,stroke:#28a745
style EC fill:#d4edda,stroke:#28a745
CascadeContext¶
A lightweight, immutable record containing only the data that must propagate from parent to child messages.
CascadeContext
public sealed record CascadeContext {
public required CorrelationId CorrelationId { get; init; }
public required MessageId CausationId { get; init; }
public SecurityContext? SecurityContext { get; init; }
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
Properties¶
| Property | Type | Purpose |
|---|---|---|
CorrelationId |
CorrelationId |
Links all messages in a workflow (inherited from parent) |
CausationId |
MessageId |
The parent message's MessageId (forms the causation chain) |
SecurityContext |
SecurityContext? |
UserId and TenantId for multi-tenant security |
Metadata |
IReadOnlyDictionary<string, object>? |
Extensible key-value store populated by enrichers |
Creating a Root Context¶
When starting a brand-new message flow with no parent, use the static factory methods:
Root Context Creation
// No security context
var root = CascadeContext.NewRoot();
// Inherits UserId/TenantId from ambient AsyncLocal scope
var rootWithSecurity = CascadeContext.NewRootWithAmbientSecurity();
NewRoot() generates fresh CorrelationId and CausationId values. NewRootWithAmbientSecurity() adopts an inbound correlation id captured at the edge (e.g. an X-Correlation-ID header) when one is present — otherwise minting one aligned to the ambient OpenTelemetry trace — and additionally reads the current ImmutableScopeContext from ScopeContextAccessor (if propagation is enabled) and copies UserId/TenantId into a SecurityContext.
Adding Metadata¶
CascadeContext is immutable. The WithMetadata methods return new instances:
WithMetadata
// Add a single key
var enriched = cascade.WithMetadata("FeatureFlag", "new-checkout-v2");
// Merge a dictionary (overwrites existing keys)
var additional = new Dictionary<string, object> {
["ExperimentId"] = "exp-42",
["Region"] = "eu-west-1"
};
var merged = cascade.WithMetadata(additional);
SecurityContext (Observability)¶
The SecurityContext carried by CascadeContext is a simple record capturing authentication identity at a point in time:
SecurityContext
public record SecurityContext {
public string? UserId { get; init; }
public string? TenantId { get; init; }
}
This is distinct from the richer IScopeContext (which includes roles, permissions, and claims). SecurityContext is intentionally minimal because it travels inside message envelopes across service boundaries.
CascadeContextFactory¶
The factory centralizes creation and enrichment of CascadeContext. Register it as a singleton:
CascadeContextFactory Registration
Factory Methods¶
CascadeContextFactory API
public sealed class CascadeContextFactory(IEnumerable<ICascadeContextEnricher>? enrichers) {
// From an incoming envelope (most common in workers)
public CascadeContext FromEnvelope(IMessageEnvelope envelope);
// From an existing IMessageContext (e.g., in receptors)
public CascadeContext FromMessageContext(IMessageContext messageContext);
// New root for entry points (HTTP controllers, background jobs)
public CascadeContext NewRoot();
}
| Method | CorrelationId | CausationId | Security |
|---|---|---|---|
FromEnvelope |
From envelope's first hop (or new) | Envelope's MessageId |
Ambient preferred, envelope fallback |
FromMessageContext |
From message context | Message context's MessageId |
Message context preferred, ambient fallback |
NewRoot |
New | New | Ambient |
All three methods apply registered ICascadeContextEnricher instances in registration order before returning.
Typical Usage¶
Using CascadeContextFactory in a Worker
public class OrderWorker {
private readonly CascadeContextFactory _cascadeFactory;
private readonly IDispatcher _dispatcher;
public OrderWorker(CascadeContextFactory cascadeFactory, IDispatcher dispatcher) {
_cascadeFactory = cascadeFactory;
_dispatcher = dispatcher;
}
public async Task HandleAsync(IMessageEnvelope envelope, CancellationToken ct) {
// Extract cascade context from the incoming envelope
var cascade = _cascadeFactory.FromEnvelope(envelope);
// Create a child message context inheriting correlation + security
var childContext = MessageContext.Create(cascade);
// The child message carries the same CorrelationId,
// has CausationId pointing to the parent, and
// inherits UserId/TenantId for multi-tenant filtering
}
}
ICascadeContextEnricher¶
Enrichers inject custom data into the cascade context during factory creation. They run in registration order and must be stateless, idempotent, and thread-safe.
ICascadeContextEnricher
public interface ICascadeContextEnricher {
CascadeContext Enrich(CascadeContext context, IMessageEnvelope? sourceEnvelope);
}
Example: Feature Flag Enricher¶
FeatureFlagEnricher
public class FeatureFlagEnricher : ICascadeContextEnricher {
private readonly IFeatureFlagService _flags;
public FeatureFlagEnricher(IFeatureFlagService flags) {
_flags = flags;
}
public CascadeContext Enrich(CascadeContext context, IMessageEnvelope? sourceEnvelope) {
var activeFlags = _flags.GetActiveFlags(context.SecurityContext?.TenantId);
return context.WithMetadata("ActiveFeatureFlags", activeFlags);
}
}
// Register in DI
services.AddSingleton<ICascadeContextEnricher, FeatureFlagEnricher>();
Enricher Guidelines¶
- Return the same instance if no enrichment is needed (avoid unnecessary allocations)
- Use
withexpressions orWithMetadata()to create modified copies - Never throw exceptions - log and return the original context if enrichment fails
- Enrichers receive
nullforsourceEnvelopewhen called fromNewRoot()orFromMessageContext()
IScopeContext & Scope Context Accessor¶
IScopeContext provides the rich security context (roles, permissions, claims, security principals) for the current operation. It is populated from HTTP claims, message headers, or explicit injection.
IScopeContext
public interface IScopeContext {
PerspectiveScope Scope { get; } // TenantId, UserId
IReadOnlySet<string> Roles { get; }
IReadOnlySet<Permission> Permissions { get; }
IReadOnlySet<SecurityPrincipalId> SecurityPrincipals { get; }
IReadOnlyDictionary<string, string> Claims { get; }
string? ActualPrincipal { get; }
string? EffectivePrincipal { get; }
SecurityContextType ContextType { get; }
bool HasPermission(Permission permission);
bool HasRole(string roleName);
bool IsMemberOfAny(params SecurityPrincipalId[] principals);
// ... additional authorization methods
}
IMessageContext.ScopeContext¶
Messages own and carry their scope context. When IMessageContext is created, it captures the current IScopeContext so that the message carries its authorization state throughout its lifecycle:
IMessageContext ScopeContext
public interface IMessageContext {
// ... MessageId, CorrelationId, CausationId, etc.
// The message OWNS and CARRIES its scope context
IScopeContext? ScopeContext { get; }
}
This is critical for deferred lifecycle stages (like PostPerspectiveDetached) where the original HTTP context is no longer available. The scope context persists because the message carries it.
Initiating Context¶
The Initiating Context is the IMessageContext that started the current scope. It serves as the source of truth for security identity (UserId, TenantId).
IScopeContextAccessor
public interface IScopeContextAccessor {
IScopeContext? Current { get; set; }
// SOURCE OF TRUTH for security context
IMessageContext? InitiatingContext { get; set; }
// Pointer properties - read directly from InitiatingContext
string? UserId => InitiatingContext?.UserId;
string? TenantId => InitiatingContext?.TenantId;
// Rich authorization context
IScopeContext? ScopeContext => Current;
}
Why InitiatingContext Matters¶
In event-sourcing systems, messages carry state. The InitiatingContext stores the IMessageContext that began the current processing scope, providing:
- Single source of truth for
UserIdandTenantId - Full tracing context (
MessageId,CorrelationId,CausationId) - Debugging support - inspect the exact message that initiated the scope
ScopeContextAccessor (AsyncLocal Implementation)¶
ScopeContextAccessor uses AsyncLocal<T> for ambient context that flows across async calls:
ScopeContextAccessor Static Accessors
public sealed class ScopeContextAccessor : IScopeContextAccessor {
// Static accessors for singleton services (e.g., Dispatcher)
public static IScopeContext? CurrentContext { get; set; }
public static IMessageContext? CurrentInitiatingContext { get; set; }
public static string? CurrentUserId => CurrentInitiatingContext?.UserId;
public static string? CurrentTenantId => CurrentInitiatingContext?.TenantId;
}
Priority Resolution¶
CurrentContext resolves with the following priority:
_currentif it is anImmutableScopeContextwithShouldPropagate = true(set byReceptorInvokerfor security propagation to cascaded events)InitiatingContext.ScopeContext(the message context's owned scope)_current(backward-compatibility fallback)
This ensures that when security infrastructure explicitly sets an ImmutableScopeContext with propagation enabled, it takes precedence.
Correlation Propagation — One Resolver¶
Correlation and causation must flow unchanged down a whole causal tree: an inbound command, every event it produces, every event those events produce, and so on, all share one correlation id, while causation links each message to its immediate parent. Whizbang decides this in exactly two places — a matched pair — so the rule lives in one spot instead of being re-implemented at every context-establishment site:
| Resolver | Side | Used when |
|---|---|---|
CascadeContext.ResolveHopFirstIdentity(sourceEnvelope) |
publish | stamping the hop of an event being emitted (outbox / event-store hop builders) — the source hop captured at the call site is authoritative, falling back to ResolveCascadeIdentity (ambient initiating context, then a trace-aligned fresh root) |
CascadeContext.ResolveInheritedIdentity(sourceEnvelope) |
handle | establishing the message context of a message being processed (transport workers, the local cascade, the receptor invoker, the perspective worker, composite fan-out) |
ResolveInheritedIdentity applies a fixed priority:
- The message's own envelope hop — an inbound message carries its authoritative correlation/causation.
- The ambient parent message context — a locally-cascaded message has no envelope, so it inherits from the receptor whose handler emitted it. This branch also rescues an inbound message whose hop somehow lost its correlation, so a dropped hop can never silently fork a fresh correlation tree (defence in depth).
- A fresh root — only when there is genuinely no parent (a true entry point).
ResolveInheritedIdentity
// Every message-context establishment site routes through this — never re-implement the rule inline.
var (correlation, causation) = CascadeContext.ResolveInheritedIdentity(envelope);
var messageContext = new MessageContext {
MessageId = envelope.MessageId,
CorrelationId = correlation, // hop -> ambient parent (rescue) -> fresh root
CausationId = causation,
// ... scope, user, tenant ...
};
Why one resolver? Correlation resolution used to be copy-pasted (
envelope.GetCorrelationId() ?? CorrelationId.New()) across every establishment site. Each copy independently decided what to do when the correlation was missing, and one site at a time drifted into minting a fresh correlation — which orphaned everything downstream (for example, a saga-trigger's event and its completion notification) onto a brand-new tree. Routing every site throughResolveInheritedIdentitymeans there is a single place to get this right, and the ambient-parent rescue removes the silent-fresh failure mode entirely.
Pointer Properties¶
Both IScopeContextAccessor and ScopeContextAccessor expose UserId and TenantId as pointer properties. They are not copies - they read directly from InitiatingContext:
Pointer Properties
// IScopeContextAccessor interface default implementations
string? UserId => InitiatingContext?.UserId;
string? TenantId => InitiatingContext?.TenantId;
// ScopeContextAccessor static equivalents
public static string? CurrentUserId => CurrentInitiatingContext?.UserId;
public static string? CurrentTenantId => CurrentInitiatingContext?.TenantId;
If InitiatingContext changes (e.g., a new message enters the processing scope), the pointer properties automatically reflect the new values with no stale-copy risk.
ScopedMessageContext¶
ScopedMessageContext is an internal DI-injectable IMessageContext that reads from both IMessageContextAccessor and IScopeContextAccessor. It provides the correct UserId and TenantId by applying a priority chain:
| Priority | Source | When Used |
|---|---|---|
| 1 | InitiatingContext |
SOURCE OF TRUTH - the IMessageContext that started this scope |
| 2 | IScopeContext.Scope |
Populated from envelope hop SecurityContext |
| 3 | IMessageContextAccessor.Current |
Backward-compatibility fallback |
This ensures tenant context is always available, even in deferred lifecycle stages like PostPerspectiveDetached where the original HTTP context is gone.
MessageContext.New()¶
MessageContext.New() captures the current ambient scope so the message owns and carries its security context:
MessageContext.New()
// Priority for UserId/TenantId:
// 1. InitiatingContext (SOURCE OF TRUTH)
// 2. CurrentContext.Scope (backward-compatibility fallback)
var context = MessageContext.New();
// context.UserId = captured from ambient scope
// context.TenantId = captured from ambient scope
// context.ScopeContext = captured IScopeContext (message now OWNS it)
Creating from CascadeContext¶
When creating child messages from a cascade:
MessageContext.Create(cascade)
var cascade = cascadeFactory.FromEnvelope(envelope);
var childContext = MessageContext.Create(cascade);
// childContext.CorrelationId = cascade.CorrelationId (inherited)
// childContext.CausationId = cascade.CausationId (parent's MessageId)
// childContext.UserId = cascade.SecurityContext?.UserId
// childContext.TenantId = cascade.SecurityContext?.TenantId
// childContext.MessageId = new (unique per message)
End-to-End Flow¶
Here is how cascade context flows through a complete message lifecycle:
graph TB
S1["1. HTTP Request arrives<br/>ScopeContextAccessor.Current = IScopeContext (from claims)<br/>ScopeContextAccessor.InitiatingContext = IMessageContext"]
S2["2. Controller dispatches command<br/>CascadeContextFactory.NewRoot()<br/>Enrichers add metadata<br/>CascadeContext { CorrelationId, CausationId, SecurityContext }"]
S3["3. Receptor processes command, emits event<br/>CascadeContextFactory.FromMessageContext(messageContext)<br/>Event gets new MessageId, inherits CorrelationId<br/>CausationId = command's MessageId"]
S4["4. Event published to outbox → Service Bus<br/>Envelope carries SecurityContext in hops"]
S5["5. Worker receives event<br/>CascadeContextFactory.FromEnvelope(envelope)<br/>Ambient security restored from envelope hops<br/>Child messages inherit full context chain"]
S1 --> S2 --> S3 --> S4 --> S5
style S3 fill:#d4edda,stroke:#28a745
style S4 fill:#fff3cd,stroke:#ffc107
Best Practices¶
DO¶
- Let
CascadeContextFactoryhandle context creation (never manually assembleCascadeContext) - Register enrichers as singletons - they must be stateless
- Use
InitiatingContextas the source of truth forUserId/TenantId - Rely on
ScopedMessageContextin receptors for correct security resolution
DON'T¶
- Mutate
CascadeContextdirectly (usewithorWithMetadata) - Store sensitive data in
Metadata(it travels across service boundaries) - Bypass the factory to create
CascadeContextmanually in production code - Assume
SecurityContextis always non-null (it is null for unauthenticated or system-initiated flows)
Further Reading¶
Message Infrastructure: - Message Context & Tracing - MessageId, CorrelationId, CausationId deep dive - Message Context Extraction - How context is extracted from envelopes
Security: - Security - IScopeContext, authorization, and multi-tenant security
Messaging: - Message Envelopes - Hop-based observability and envelope structure - Outbox Pattern - Reliable messaging with context propagation
Version 1.0.0 - Foundation Release | Last Updated: 2026-03-26