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.
What an append is guarded by
Section titled “What an append is guarded by”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 through | The scope narrows to the append’s |
|---|---|
| No definition | event source id, event source type, event stream type and event stream id |
A definition whose dimensions are None | the same four values, as without a definition |
| A definition with dimensions | only 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.
Which dimensions apply
Section titled “Which dimensions apply”Chronicle picks the dimensions with ConcurrencyFor:
- If the stream declares any dimensions, those are used. The event source’s dimensions are ignored.
- Otherwise the event source’s dimensions are used.
- 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; }}With that definition, the scopes are:
| Append | Dimensions used | Scope narrows to |
|---|---|---|
Onboarding stream | the event source’s: EventSourceId | Every event of this account, in any stream |
Transactions stream, month 2026-04 | the stream’s: EventSourceId, EventStreamType, EventStreamId | Events of this account in Transactions with stream id 2026-04 |
| No stream | the event source’s: EventSourceId | Every 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.
What conflicts
Section titled “What conflicts”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 to | Second writer appends to | Conflict? |
|---|---|---|
Account A, Transactions, 2026-04 | Account A, Transactions, 2026-04 | Yes |
Account A, Transactions, 2026-04 | Account A, Transactions, 2026-05 | No |
Account A, Transactions, 2026-04 | Account B, Transactions, 2026-04 | No |
Account A, Transactions, 2026-04 | Account A, Onboarding | Yes. The onboarding scope covers the whole account |
Account A, Onboarding | Account A, Transactions, 2026-04 | No. 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; }}The first append into a scope
Section titled “The first append into a scope”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.
Build an explicit scope
Section titled “Build an explicit scope”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; }}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
IncompatibleConcurrencyScopesForEventSourcebefore anything is written, and the exception lists the guards inScopes.
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; } }}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; }}Batches that do not use definitions behave as before.
Next steps
Section titled “Next steps”- Scenarios: the dimensions in worked examples.
- How it works: the code path from definition to scope.
- Dynamic Consistency Boundary: scopes as append conditions. See also Dynamic Consistency Boundary in Chronicle.
- Decision reads