Skip to content

Concurrency per event source and stream

Use this page to decide which appends should conflict. The general model of concurrency scopes is in Concurrency. This page covers what a definition adds to it.

When you append without passing a ConcurrencyScope, the client reads the tail of the event sequence and sends it as the position you expect. The kernel rejects the append if an event that matches the scope was appended after that position. What “matches” means is the part a definition changes:

The append goes throughThe scope narrows to the append’s
No definitionevent source id, event source type, event stream type and event stream id
A definition whose dimensions are Nonethe same four values, as without a definition
A definition with dimensionsonly the dimensions it declares

The client reads the tail with exactly the narrowing it sends, so the position and the check ask the same question. A dimension that is not declared is left out, not set to a default.

Chronicle picks the dimensions with ConcurrencyFor:

  1. If the stream declares any dimensions, those are used. The event source’s dimensions are ignored.
  2. Otherwise the event source’s dimensions are used.
  3. An append without a stream uses the event source’s.

A stream cannot opt out. None on a stream means “not declared”, so it inherits. To make a stream narrower or wider than its event source, declare the dimensions on the stream.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSources;
[EventType]
public record MoneyDeposited(decimal Amount);
[EventType]
public record MoneyWithdrawn(decimal Amount);
// Every event of an account conflicts with every other event of the same account, except in the
// Transactions stream, where only events of the same month conflict.
[EventSource("Account", Concurrency = ConcurrencyDimensions.EventSourceId)]
[EventStream("Onboarding")]
[EventStream(
"Transactions",
Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)]
public class AccountEventSource : IEventSource;
public class MonthlyDeposits(IEventLog eventLog)
{
// No scope is passed, so Chronicle builds one from the dimensions: it reads the tail of this
// account's Transactions stream for this month and expects it to be unchanged at append time.
public async Task<bool> Deposit(EventSourceId accountId, string month, decimal amount)
{
var result = await eventLog.Append<AccountEventSource>(
accountId,
new MoneyDeposited(amount),
eventStream: "Transactions",
eventStreamId: month);
return result.IsSuccess;
}
}

View C# snippet source on GitHub

With that definition, the scopes are:

AppendDimensions usedScope narrows to
Onboarding streamthe event source’s: EventSourceIdEvery event of this account, in any stream
Transactions stream, month 2026-04the stream’s: EventSourceId, EventStreamType, EventStreamIdEvents of this account in Transactions with stream id 2026-04
No streamthe event source’s: EventSourceIdEvery event of this account

No scope names event types, so any event type in the narrowed range counts. That makes a definition-derived guard coarse: it protects a whole event source or a whole stream, as an aggregate does. It is the right guard when the stream is the consistency boundary. When a rule needs a narrower, event-type-filtered append condition, use a decision read or an explicit scope with WithEventType. The check is asymmetric: a Transactions deposit does not look at Onboarding events, but an Onboarding append sees every event of the account, including deposits.

Two writers read the tail, then both append. The second append is rejected when the first one’s event is inside the second one’s scope.

First writer appends toSecond writer appends toConflict?
Account A, Transactions, 2026-04Account A, Transactions, 2026-04Yes
Account A, Transactions, 2026-04Account A, Transactions, 2026-05No
Account A, Transactions, 2026-04Account B, Transactions, 2026-04No
Account A, Transactions, 2026-04Account A, OnboardingYes. The onboarding scope covers the whole account
Account A, OnboardingAccount A, Transactions, 2026-04No. The transactions scope does not cover onboarding

A conflict is a normal result, not an exception. The append returns with HasConcurrencyViolations set and nothing is written. Read the new state, decide again and append again.

using Cratis.Chronicle.EventSequences;
public class DepositWithRetry(IEventLog eventLog)
{
public async Task<bool> Deposit(EventSourceId accountId, string month, decimal amount)
{
var result = await eventLog.Append<AccountEventSource>(
accountId,
new MoneyDeposited(amount),
eventStream: "Transactions",
eventStreamId: month);
if (result.HasConcurrencyViolations)
{
// Another writer appended to this month's stream between the tail read and the write.
// Nothing was appended. Read the new state and decide again.
return false;
}
return result.IsSuccess;
}
}

View C# snippet source on GitHub

A narrowed scope needs an event to compare with. When no event matches the narrowing yet, such as the first deposit of a new month, the client has no position to send and the kernel skips the check. By default two writers can both make the first append into a new stream and both succeed.

Turn on ConcurrencyOptions.CheckFirstAppendIntoAScope, or build the scope with ExpectingNoMatchingEvent() for one append, to check it. See The first append into a scope is not checked by default. The result’s ConcurrencyCheckPerformed tells you whether a check ran.

An append that passes its own ConcurrencyScope skips the dimension logic. This is how you guard a decision you made from earlier reads: capture the tail first, read the facts, decide, then append with that position.

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
public class DecideThenDeposit(IEventLog eventLog)
{
public async Task<bool> Withdraw(EventSourceId accountId, string month, Func<decimal> decide)
{
// Capture the position of this month's stream before you read the facts you decide on.
var tail = await eventLog.GetTailSequenceNumber(
eventSourceId: accountId,
eventSourceType: "Account",
eventStreamType: "Transactions",
eventStreamId: month);
var amount = decide();
// An empty stream has no tail (Unavailable). Explicit scopes skip that value, so expect "no event yet".
if (tail.IsUnavailable)
{
tail = EventSequenceNumber.BeforeFirst;
}
// ForEventSource names the source type, the stream type and the stream id from the definition.
// The builder does not set the event source id or the position: you do.
var scope = new ConcurrencyScopeBuilder()
.WithEventSourceId(accountId)
.ForEventSource<AccountEventSource>("Transactions", month)
.WithSequenceNumber(tail)
.Build();
var result = await eventLog.Append<AccountEventSource>(
accountId,
new MoneyWithdrawn(amount),
eventStream: "Transactions",
eventStreamId: month,
concurrencyScope: scope);
return result.IsSuccess;
}
}

View C# snippet source on GitHub

ForEventSource<TSource>(stream, streamId) copies the event source type, and the stream type and stream id when you give them, from the definition. It does not read the dimension flags. It does not set the event source id or the sequence number either: you add WithEventSourceId and WithSequenceNumber. The builder only checks that the stream you name is declared by the definition, and throws EventStreamDoesNotBelongToEventSource if it is not. It never sees the append. If you name one stream in ForEventSource and append to another, the scope is accepted and guards the wrong stream. You must align an explicit guard with what you read and what you write.

The tail from GetTailSequenceNumber is Unavailable when the stream has no events. An explicit scope is used as given, so OptimisticConcurrencyStrategy does not convert that value for you, and the kernel skips the check: two competing decisions would both append. Convert it to EventSequenceNumber.BeforeFirst, as the example does, so the scope means “no matching event yet”.

A decision read does the capture-then-read sequence for you. See Dynamic Consistency Boundary.

A batch that needs two guards for one event source id

Section titled “A batch that needs two guards for one event source id”

An AppendMany carries one concurrency scope per event source id. If you append events through definitions and pass no scope for an id, Chronicle resolves the guard of every event with that id and compares them by what they check: event source id, event source type, event stream type, event stream id and event types. The expected sequence number is not compared, because it is a point-in-time value.

  • Events whose guards check the same thing share one scope.
  • An event whose guard checks nothing, for example because it is the first append into its scope, never hides a later event that does have a guard.
  • Events that need different guards cannot be sent together. Chronicle throws IncompatibleConcurrencyScopesForEventSource before anything is written, and the exception lists the guards in Scopes.
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
public class CatchUpPosting(IEventLog eventLog)
{
public async Task<string> PostTwoMonths(EventSourceId accountId, decimal january, decimal february)
{
EventForEventSourceId[] events =
[
new(accountId, new MoneyDeposited(january))
{
EventSource = typeof(AccountEventSource),
EventStream = "Transactions",
EventStreamId = "2026-01"
},
new(accountId, new MoneyDeposited(february))
{
EventSource = typeof(AccountEventSource),
EventStream = "Transactions",
EventStreamId = "2026-02"
}
];
try
{
var result = await eventLog.AppendMany(events);
return result.IsSuccess ? "Posted" : "Rejected";
}
catch (IncompatibleConcurrencyScopesForEventSource exception)
{
// Nothing was appended. The two events need guards on different streams of one event source id.
return exception.Message;
}
}
}

View C# snippet source on GitHub

This batch throws once both months already hold events, because each month needs its own guard. If neither stream has events and the first-append check is off, neither event has a guard to compare and the batch is sent unguarded.

Chronicle never widens a guard to cover the difference. Choose one:

  • Pass one explicit scope for the event source id that suits every event. An explicit scope always wins.
  • Append the events in separate batches.
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
public class CatchUpPostingWithOneScope(IEventLog eventLog)
{
public async Task<bool> PostTwoMonths(EventSourceId accountId, decimal january, decimal february)
{
EventForEventSourceId[] events =
[
new(accountId, new MoneyDeposited(january))
{
EventSource = typeof(AccountEventSource),
EventStream = "Transactions",
EventStreamId = "2026-01"
},
new(accountId, new MoneyDeposited(february))
{
EventSource = typeof(AccountEventSource),
EventStream = "Transactions",
EventStreamId = "2026-02"
}
];
// One explicit scope for the account that suits both events: every month of the Transactions stream.
var tail = await eventLog.GetTailSequenceNumber(eventSourceId: accountId, eventStreamType: "Transactions");
if (tail.IsUnavailable)
{
// Nothing in the stream yet: expect that no event appears before the append.
tail = EventSequenceNumber.BeforeFirst;
}
var scope = new ConcurrencyScopeBuilder()
.WithEventSourceId(accountId)
.WithEventStreamType("Transactions")
.WithSequenceNumber(tail)
.Build();
var result = await eventLog.AppendMany(
events,
concurrencyScopes: new Dictionary<EventSourceId, ConcurrencyScope> { [accountId] = scope });
return result.IsSuccess;
}
}

View C# snippet source on GitHub

Batches that do not use definitions behave as before.