Skip to content

Choosing how a rule holds

Use this page when you append events with the Chronicle client yourself (a minimal API, a controller, a worker, a console app) and a rule must stay true while other writers append at the same time. The code on these pages uses the .NET client. If you use Cratis Arc commands, start with Choosing how a command’s rule holds instead. It builds on this page.

  • A value must be unique, or something may be created only once: declare a constraint. The kernel enforces it for every writer.
  • A rule depends on state you read first (a count, a stock, a status, a limit): use a decision read and append with guardedBy. The append is rejected if a relevant event arrived after the read.
  • A user edited something someone else changed in the meantime: append with an explicit concurrency scope built from the revision the user saw.
  • A rule spans two keys: take one decision read per key and append one event per event source in a single AppendMany.
  • A plain read never makes a rule hold. An injected read model or IReadModels shows state. It does not capture a position and does not guard the append that follows.

Everything beyond this list is about picking between these, and about what each one costs.

The Dynamic Consistency Boundary pattern makes an append conditional on the facts a decision used. Chronicle’s mapping has the details. In short:

  • Chronicle expresses the condition. A concurrency scope holds a position and a filter. A decision read builds the scope for you and notes the position before it folds the state.
  • Chronicle adds constraints and checks. Constraints enforce uniqueness for every writer without a read, and a decision read refuses shapes it cannot guard exactly.
  • Chronicle does not do everything DCB can. Tags take no part in consistency, and an event has one event source ID, so a fact about two entities is two events in one AppendMany. DCB examples in Chronicle shows the Chronicle answer to each canonical DCB example.
Rule shapeUseAlternativesNever useToo much
A value is unique (username, email, ISBN)Constraint: [Unique] on the property, or an IConstraintNone needed. Choose per event source or per value, and release with a removal eventReading a read model first, then appendingA decision read or a scope: they guard one key’s history, not a value across keys
Something is created onceConstraint: [Unique] on the event type (one event of that type per event source)ExpectingNoMatchingEvent() on the append for a one-off that is not worth a constraint“Does it exist?” read, then appendA decision read
A rule over one key’s history (cannot ship before payment, cannot cancel twice)Decision read of a flat projection keyed by that event sourceAn explicit scope, with the tail captured before you read the eventsA plain read modelHand-rolled folds when a projection can express the state
Stock or count on one key (copies left, loans per member)Decision read of a projection with [Increment] and [Decrement]An explicit scope on the event types you countedA materialized counter read with no guardNone, but see the cost of a hot key below
A rule across two keys (course capacity and a student’s course limit)One decision read per key, one event per event source, one AppendManyPut every fact the rule needs on one event source, then use one readTwo separate appends, or a guard on only one sideNone
An editing conflict (two people change the same record)Explicit scope built from the revision the user sawA decision read when the conflict only matters inside one requestLast writer wins by accidentA constraint
One stream inside an event sourceA decision read scoped to the stream type and stream ID. Declare the stream with an event source definition so the read and the append share its names, see Event sources and the Dynamic Consistency BoundaryAn explicit scope with stream type and stream IDA read over the whole source when only one stream matters: you get conflicts from other streamsNone
Rich behaviour (many rules over one lifecycle)A decision read of a richer flat projection. In Arc, an aggregate rootSeveral decision reads, one per concernRules spread over validators and plain read-model checksAn aggregate where one counter rule would do
A rare violation is acceptableAppend normally, then repair: a reactor appends a correcting eventA constraint for whatever part is cheap to enforcePresenting a plain read as a guaranteeA decision read
A UX hint (disable a button, show “seats left”)A plain read of a materialized read modelNoneTreating the hint as enforcementA decision read: it costs several calls and a full fold
The rule depends on data outside ChronicleRecord the external fact as an event first, then decide on the eventAccept the risk and repairAssuming a decision read covers itNone
A hot key with a long historyKeep each key’s history short: split the key by period or by streamA constraint if the rule is uniqueness. Accept an advisory read and repairIgnoring the cost: every decision read folds the key’s whole history againNone

yes

no

no

yes

yes

no

no

yes

a revision the user saw

state on one key

state on two or more keys

A write must be correct while others write

A unique value, or created once?

Constraint

Does the rule depend on state you read first?

Validate the input. No guard needed

Is a rare violation acceptable?

Append normally and repair with a reactor

Does the state live in Chronicle events?

Record the external fact as an event, or accept and repair

What is the decision based on?

Explicit concurrency scope with that revision

Decision read on that key

One decision read per key and one AppendMany

Scenarios shows each branch as code.

OptionSees committed eventsCaptures a position before it readsGuards the appendUse it for
ConstraintNot a read. Checked inside the appendNot neededYes, for every writerUniqueness, create once, closed streams
Decision readYes. It folds the key’s events in a fresh sessionYes, before the foldYes: events of the model’s types for that key after the positionState rules on one or a few keys
Explicit concurrency scopeWhatever you readOnly if you capture the tail before you readYes: events that match the scope after the positionRevisions the user saw, rules you read yourself
Default optimistic appendNot a readReads the tail at append timeOnly the window between that read and the writeNothing that depends on state
Plain read of a materialized read model (projection, reducer or self-reducing model)No. It can lag the logNoNoDisplay, hints
Plain read of a passive projectionYes, at read timeNoNoDisplay, checks you will repair
Plain read of a passive reducer (classic or self-reducing)Yes, at read timeNoNo. Decision reads refuse reducersDisplay, checks you will repair
Reading the sink’s database directlyNo. Same lag as a materialized readNoNoReports. It couples you to the sink’s schema, so avoid it in command logic

Freshness depends on whether a read model is materialized or passive, not on whether a projection, a reducer or a self-reducing model builds it. Every read model is materialized unless it is passive: [Passive] on the read model, or .Passive() on a declarative projection. Materialized read models are stored and updated after the append returns. Passive ones are not stored. Chronicle folds them from the events when you read them. Passive reads are fresher, but neither kind records where in the log the read stopped, so neither can guard the append that follows.

Capturing the tail yourself before a plain read does not fix this. A materialized read model can lag the log, so the state you see can be older than the position you captured, and the events in between are neither seen nor guarded. A decision read folds a fresh session and checks that the fold reached the events it expected, which is why you should not rebuild it by hand.

This is the shape that looks safe and is not:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.ReadModels;
[EventType]
public record SeatOpened(string Row);
[EventType]
public record SeatClaimed(string ClaimedBy);
[FromEvent<SeatOpened>]
public record SeatAvailability(
Guid Id,
[Count<SeatClaimed>]
int Claims);
public static class ClaimSeatWithAPlainRead
{
// Do not use this to make a rule hold. Two requests can both read Claims = 0
// and both append. The read guards nothing.
public static async Task<bool> Claim(IReadModels readModels, IEventLog eventLog, Guid seatId, string claimedBy)
{
var seat = await readModels.GetInstanceById<SeatAvailability>(seatId);
if (seat.Claims > 0)
{
return false;
}
var result = await eventLog.Append(seatId, new SeatClaimed(claimedBy));
return result.IsSuccess;
}
}

View C# snippet source on GitHub

Two requests can both read Claims as 0 and both append. A constraint, or a decision read on the seat, closes that race.

In Arc, a CommandValidator<T> that reads a read model is also a plain read. See Validators and protected decisions.

A decision read does not use the stored read model. For each attempt the client:

  1. Asks the kernel for the log’s tail and for the tail of the key’s model event types, in parallel.
  2. Folds the key’s events in a fresh session and starts a session cleanup request without waiting for it.
  3. Retries, up to three attempts in total, if the fold stopped short of the second tail or ran past the first.

The first read of each model type in a process makes two more calls to list definitions. At commit the kernel runs one filtered tail query for each guarded key, in parallel. An append with no guard of your own still gets the default optimistic check: the client reads a tail when the append is made (the first append into a scope is unchecked unless configured otherwise) and the kernel validates it at commit. That protects only the short window between that read and the commit, not the decision you made earlier.

MechanismRound tripsWork per readGrows with
ConstraintNone extraAn index lookup inside the appendNot expected to grow with history
Explicit scopeOne tail call to capture the positionOne filtered tail query at commitNot measured
Materialized plain read1Reads the stored instanceNothing
Passive plain read1The first read folds the key’s history. Later reads apply newer events while the kernel keeps the instance in memoryHistory, on first read
Decision readAbout 4 per attempt, up to 3 attempts, plus 2 on first use of a typeFolds the key’s whole history of the model’s event types, every timeHistory times reads

A decision read is linear in the key’s history and nothing caches it. Reads of one model type are expected to queue behind each other in the kernel, across keys. That has not been measured.

One run, on a loaded machine, so read the ratios and not the milliseconds.

Operation (median ms)10 events on the key100 events on the key
Materialized GetInstanceById2.15.3
Passive, instance already in memory3.78.7
Passive, fresh fold8.113.9
Decision read (GetDetached)12.722.6
Tail call, then fetch the key’s events and fold them yourself5.319.2
  • Environment: the cratis/chronicle:19.54.0-development container with the Cratis.Chronicle 19.54.0 client, on an Apple M4 Pro with 12 cores and a load average of 80 to 100. The log held 20 other event sources with 500 events each. Each cell is the median of 20 samples. It is one run, and it was not repeated on later versions.
  • Result: a decision read took about 1.6 times a fresh passive fold and 4 to 6 times a materialized read. It added roughly 10 to 20 ms over a materialized read.
  • Guarded append: the extra cost of the guarded commit was lost in the noise. A read plus guarded append totalled 31.7 ms at 10 events and 19.9 ms at 100 events. The drop is noise, not a trend.
  • Not measured: 1,000 and 10,000 events on the key, concurrent writers, the SQL storage provider, and the kernel queueing described above. The cost at those sizes follows the measured growth from 10 to 100 events (the decision read roughly doubled for ten times the events) and the linear fold. Measure your own model before you rely on it.
Do not use it whenBecauseUse instead
The rule is uniqueness or create-onceA constraint is checked inside the append, costs no extra reads and covers every writerConstraint
The read feeds a screen or a hintYou pay for a fresh fold and a guard you do not useA plain read
The state is a reducer, a self-reducing read model, or has joins, children, derivatives or a key that is not the event source IDChronicle refuses these shapes. A guard it cannot compute exactly would be wrongA flat projection keyed by event source. See admitted projections
The key has a long history and many writersEach read folds the whole history and conflicts rise with the write rateShorter keys, or accept an advisory read and repair
The data lives outside the Chronicle event logNothing in the log can invalidate itRecord the fact as an event

Chronicle does not guard a decision unless you ask. An Append with no scope gets only the default optimistic check, which covers the moment between reading the tail and writing. A decision read protects only the append you pass it to. In Arc, a command opts in with [ProtectedDecision].

  • Existing code keeps its behavior. The Arc change that introduced protected decisions states that unmarked commands keep their current behavior. Turning protection on everywhere would change what many existing commands do.
  • Protection fails closed. A protected path refuses what it cannot guard exactly: reducers, joins, validators with dependencies, and commits of the unit of work by anyone but its owner, such as an aggregate’s own Commit(). Code that works today and guards nothing would start to fail.
  • It has a price. The cost above is acceptable per rule. It is not free for every read.
  • Other systems choose differently. The DCB specification makes the append condition optional per append. Axon Framework 5 attaches the consistency marker automatically for a sourced entity. KurrentDB defaults to no check.

There is no application-wide switch in Arc or Chronicle that makes protection the default.

“Silent” means the rule can fail with no error and no failed result.

SituationWhat happensSilent or loudWhat to do
You ignore the AppendResultA constraint or concurrency rejection is a result, not an exception. The write is lostSilentAlways check IsSuccess, then the specific violations
You commit the request’s unit of work after the responseThe ASP.NET Core middleware commits after the action returns. It does not report a concurrency conflict to the callerSilent to the callerCall IUnitOfWorkCompletionFeature.CommitAsync() before you respond
You take a decision read through the unit of work, then call plain eventLog.AppendThe append is immediate and takes no part in the unit of work, so the guard never appliesSilentStage with eventLog.Transactional.Append, or use GetDetached with AppendMany(guardedBy:)
A scope has no sequence number and no ExpectingNoMatchingEvent()The kernel skips itSilent. ConcurrencyCheckPerformed is falseBuild scopes with a position, and assert ConcurrencyCheckPerformed in tests
You capture the tail after you read the factsEvents appended in between are not guardedSilentCapture the tail first, or use a decision read
The first append into a scopeNot checked by default, so two concurrent creators both passSilentUse a constraint, ExpectingNoMatchingEvent(), or a decision read: it guards an absent model too. See the first append
You use a plain read for the decisionIt guards nothingSilentSee the table above
The decision-read model has no Id property, or its shape is refusedDecisionReadRefused, with a reasonLoudFix the model. Check with Admit<T>() in a test
The read model is keyed on a different event source than the one where the facts are writtenThe model is empty or partial, and the guard watches the wrong sourceSilentAppend every fact the rule needs on the source you read
New keys can join the set the rule counts overA key nobody read is in nobody’s guardSilentModel the rule on one key that carries the facts
Revise, redact, a migration, another event sequence, or a projection definition changed elsewhereA decision read does not detect themSilentTreat them as outside the guarantee
A writer decides on the same rule without a guardA guard detects its event whoever wrote it, but the unguarded writer’s own decision is not protected and can break the ruleSilentGuard every writer that decides on the rule, or use a constraint, which the kernel checks for every append
A conflictThe append is rejected and nothing is written. Chronicle does not retryLoudRead again and resubmit
A constraint’s claim cannot be recorded after the event is durableThe event stays. A later duplicate can pass until you reindex the constraintSilent, loggedWatch for the logged failure, and reindex
A constraint across namespaces or event sequencesConstraints do not span themSilentDesign the unique value per namespace
Non-.NET writers append GUID event source IDs in another casing or formatThe decision read’s key no longer matchesSilentUse lowercase canonical form in every writer
A long or hot keyReads get slower and conflicts more likely. Results are not wrongSlowShorten the key’s history
A repair reactor triggers an irreversible side effectA compensating event cannot undo itSilentRepair with events only, and make the repair safe to run twice
Your tests never interleave a competing appendThe race is never exercisedSilentAppend a competing event between the read and the guarded append, and assert the conflict. For Arc commands see the pitfalls

For the retry inside the kernel after a duplicate sequence number: the kernel renumbers the append and validates concurrency scopes again before it retries, so decision-read guards keep working. It does not run constraints again on that path.

Arc featureWhat it gives youWhere to read
[ProtectedDecision] with DecisionRead<T>A command that takes decision reads as parameters and commits them as the ownerChoosing how a command’s rule holds
Stream decisions (IStreamReads)A guard on every event in one streamStream decisions
Aggregate rootsA guard on the event types the aggregate handles, taken before the events are appliedAggregate roots
CommandValidator<T>Shape rules. A validator that reads state is a plain readValidators and protected decisions