Skip to content

How guards are derived

Use this page when you want to know exactly which events make an append fail. A guard is the append condition Chronicle sends to the kernel with the events: a position in the log, plus a filter. The kernel rejects the append if an event that matches the filter was appended after the position. Everything else on this page is about where the position and the filter come from.

PartWhat it decidesDecision readExplicit scopeEvent source definition
Event sourceWhose events countThe key you readWithEventSourceIdThe event’s own source, when the definition declares EventSourceId
Event typesWhich kinds of event countThe event types of the read model, including its removal typesWithEventTypeNone. Any type counts
Source type, stream type, stream IDWhich part of the source countsOnly for a stream-scoped read, with the values you passThe builderThe dimensions the definition declares
PositionWhat the writer is assumed to have seenThe log’s tail, captured before the read foldsThe sequence number you capturedThe tail, read when you append

A decision read is the only form that captures the position before it reads the facts. That is why it can guard a decision. The other two guard only what you wire up yourself. See Choosing how a rule holds.

The guard is written with a guard: line in the examples below. It is a notation for this page. IUnitOfWork.GetAppendConditions() returns the real conditions, and their ToString() prints the same parts with event type IDs.

A read of a whole source guards the source, for the model’s event types only.

// GetDetached<MemberLoans>(member-1)
guard member-1: types=[MemberRegistered, LoanTaken, LoanReturned], after <tail at read>

A BookRenamed for the same member does not conflict, because the model does not use it. A LoanTaken for another member does not conflict, because the source differs.

A stream-scoped read adds the stream. The source type is added only when you pass one.

// GetDetached<MonthlyWithdrawals>(BankAccount-7, "Transactions", "2026-10")
guard BankAccount-7: types=[MoneyWithdrawn], streamType=Transactions, streamId=2026-10, after <tail at read>

A withdrawal in 2026-11, or a withdrawal on another account, does not conflict. See A limit inside one stream.

An absent model is guarded too. When no matching event existed at the read, an event of the model’s types that arrives later conflicts. This is how “create it only if it does not exist yet” holds.

A per-stream read model declares its stream with [EventStream<TSource>("Transactions")]. Its read must carry the declared event source type, the stream type and a stream ID, so the guard keeps all three dimensions. A read without them is refused, so the guard never falls back to the whole source.

// GetDetached<WithdrawalsThisMonth>(BankAccount-7, "Transactions", "2026-10", "BankAccount")
guard BankAccount-7: types=[MoneyWithdrawn], sourceType=BankAccount, streamType=Transactions, streamId=2026-10, after <tail at read>

Under a read-protection frame, a second read in another month keeps a separate guard. Outside a frame, reads still merge as described below. CHR0064 reports a declaration that cannot produce this guard.

Under a read-protection frame, reads with different predicates keep separate guards on the same source label. Repeated reads of the same predicate retain the earliest position, including an absent model’s negative-read expectation. The kernel checks every guard atomically; unrelated streams need not conflict. Repeated labels require the concurrency-scope-conjunctions capability.

Outside a frame, reads of the same source still merge into one guard. That legacy guard can only get wider, never narrower:

What the reads shareThe merged guard
Same source, same stream and source typeThe union of the event types, from the earliest position
Same source, different stream type or stream IDThe union of the event types. Which stream values survive depends on where you commit, see below
Same source, different source typesThe source type is dropped. Every source type counts
Different sourcesOne guard per source, validated together with the append

The earliest position wins. An absent model, read before any matching event existed, counts as the earliest of all.

Where the reads are merged decides what happens to the stream values.

WhereStream type and stream IDEvent types
AppendMany(guardedBy: reads), or a unit of work without a read-protection frameKept only when both are equal. If either differs, both are droppedThe union
A unit of work under a read-protection frameEach predicate retains its stream type and ID in a separate guardEach predicate retains its own event-type filter
// Two reads on BankAccount-7: Transactions/2026-10 and Transactions/2026-11
// outside a frame
guard BankAccount-7: types=[MoneyWithdrawn], any stream, after <earlier tail>
// under a frame
guard BankAccount-7: types=[MoneyWithdrawn], streamType=Transactions, streamId=2026-10, after <October tail>
guard BankAccount-7: types=[MoneyWithdrawn], streamType=Transactions, streamId=2026-11, after <November tail>

Outside a frame, a withdrawal in any month conflicts. Under a frame, only the two guarded months conflict. Legacy widening produces extra conflicts but cannot hide one; an ordered explicit scope list is another way to retain separate stream guards.

Chronicle does not guess whether your own scope is enough. It checks.

You combineResult
A decision read and an explicit scope on one source, outside a read-protection frameThe append is refused with DecisionReadConflictsWithConcurrencyScope. Remove the scope or the read
A decision read and an explicit scope on one source, under a read-protection frameThe explicit scope is used if it covers everything the read watches and its position is not later than the read’s. Otherwise ExplicitScopeDoesNotCoverReads
Two explicit scopes on one source, under a frameDifferent predicates are retained. Contradictory expectations for the same predicate, or no guard mixed with a guard, throw ConflictingExplicitScopes with reads or ConflictingContributionsOnLabel without reads
A decision read and an aggregate-style contributed read on one sourceReadAndAggregateOnSameSource

Two scopes mean the same when they have the same position and the same expectation, and each covers the other. Scopes that mean the same are merged into one.

CHR0063 reports a scope built for one stream of a definition and passed to an append that writes another stream.

A scope covers a read when its source, source type, stream type and stream ID are each unset or equal, its event types are empty or a superset, and its position is the same or earlier.

An event source definition can declare which dimensions guard an append. How that mixes with a read depends on where the append is made.

WhereWhat the declared dimensions do to a decision read
AppendMany(guardedBy: reads) outside a frameNothing. The read’s guard replaces the definition’s default scope for that source
A unit of work under a read-protection frameRead predicates remain intact. Different declared predicates add separate conditions at their own captured positions
An explicit scope under a frameThe explicit scope must cover the declared dimensions, or the commit is refused with DeclaredRoutingNotCoveredByExplicitScope

A definition without a declared dimension, or with None, adds nothing. A declared set without EventSourceId becomes a separate guard that is not tied to the read, so it does not protect the state you read. CHR0062 reports it.

An example under a frame. The account declares the event source ID and the Transactions stream dimensions, and you read MonthlyWithdrawals for 2026-10.

// without the definition's dimensions
guard BankAccount-7: types=[MoneyWithdrawn], streamType=Transactions, streamId=2026-10, after <tail at read>
// with the definition's dimensions as an additional condition
guard BankAccount-7: types=[MoneyWithdrawn], streamType=Transactions, streamId=2026-10, after <tail at read>
guard BankAccount-7: any type, streamType=Transactions, streamId=2026-10, after <tail at routing resolution>

A deposit in 2026-10 after routing resolution now conflicts too, because the definition guards the whole month at that point. The read’s earlier boundary still guards withdrawals. That is correct when the month is the consistency boundary. It is more than you need when only withdrawals matter. Declare dimensions only for streams where every writer should see every other, such as a ledger. See Concurrency per event source and stream.

Decision reads

key + event types + stream

Merge per event source

Explicit scope

Declared routing

(definition)

One condition per predicate under a frame

validated atomically with the append

Under a read-protection frame, GetAppendConditions() returns one AppendCondition per guarded source after the commit. Use it in a test to assert that a rule is guarded by what you think. Each condition lists its Origins, such as ProtectedRead or DeclaredRouting, and the Reads behind it.

using Cratis.Chronicle;
using Cratis.Chronicle.Auditing;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.ReadModels;
using Cratis.Chronicle.Transactions;
public record AccountId(string Value) : EventSourceId(Value);
[EventType]
public record MoneyWithdrawn(decimal Amount);
// Counts the withdrawals in the stream the read is scoped to.
[Passive]
[FromEvent<MoneyWithdrawn>]
public record MonthlyWithdrawals(string Id, [Count<MoneyWithdrawn>] int Count);
public static class ShowTheGuard
{
public static async Task<IReadOnlyList<AppendCondition>> Withdraw(IEventStore eventStore, AccountId account)
{
var unitOfWork = (UnitOfWork)eventStore.UnitOfWorkManager.Begin(CorrelationId.New());
using var frame = unitOfWork.EnforceReadProtection();
var owner = unitOfWork.ClaimDecisionReadCommitOwnership();
// Account, stream type "Transactions", stream id "2026-10".
await eventStore.GetDecisionReads().GetFor<MonthlyWithdrawals>(unitOfWork, account, "Transactions", "2026-10");
unitOfWork.AddEvent(
EventSequenceId.Log,
account,
new MoneyWithdrawn(100),
Causation.Unknown(),
eventStreamType: "Transactions",
eventStreamId: "2026-10");
await unitOfWork.CommitAsOwner(owner);
// One AppendCondition per guarded event source. Print it to see what the commit guarded.
return unitOfWork.GetAppendConditions();
}
}

View C# snippet source on GitHub

For a test that races two writers without a frame, see Test the race.