Skip to content

How read protection works

Use this page when you want to know exactly which events make a command fail, or why a guard is wider than you expected. You do not need it to use read protection. Getting started is enough.

StepWhat happens
1. BeginBefore the filters run, Arc starts the command’s unit of work, claims ownership of it, and turns on read protection for the whole invocation. Nested commands share the same unit
2. Resolve parametersEach read-model parameter, in Handle(), Provide() and validator constructors, is read fresh through Chronicle’s decision-read path and enrolled as a guard before your code sees the value
3. DecideValidators run, then Provide(), then Handle(). Reads of the same model and key inside one command share one fold
4. CommitArc commits as the owner. Chronicle merges every guard into the conditions the kernel checks with the append
5. EndArc ends read protection after stream completions have run. A conflict writes nothing

Chronicle’s side of this is the read-protection frame, and Arc is the host that drives it. If you use Chronicle directly, you drive it yourself.

A guard is an append condition: 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.

PartFor a read-model parameter
Event sourceThe key the parameter reads
Event typesThe types the model folds, including the types that remove it
PositionThe tail of the log, captured before the model was folded

Capturing the position before the fold is what makes the guard sound. An event that lands during the fold, or after it, is after the position and is detected.

The examples in these pages show the guard as one line. It is a notation for the docs. IUnitOfWork.GetAppendConditions() returns the real conditions.

// BookStock for isbn-1
guard isbn-1: types=[CopyAdded, BookBorrowed, BookReturned], after <tail before the read>
Event arrives forConflicts?
The same book, one of the model’s typesYes
The same book, another type (a rename)No
Another bookNo

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

Reads of the same event source merge into one guard. The merged guard can only get wider, never narrower, so it never misses a change that either read would have caught.

What the reads shareThe merged guard
Same sourceThe union of the event types, from the earliest position
Different sourcesOne guard per source, validated together with the append
// Transfer: AccountBalance for acc-1 and AccountBalance for acc-2
guard acc-1: types=[Deposited, Withdrawn], after <tail before the first read>
guard acc-2: types=[Deposited, Withdrawn], after <tail before the second read>

Both guards are checked in the same atomic append. A conflict on either one rejects the whole batch.

If a command returns an exact scope (EventsWithConcurrencyScopes) for a source it also reads, Chronicle checks that the scope covers the read:

  • the scope’s source, types and stream dimensions are each unset or at least as wide as the read’s;
  • the scope’s position is the same as or earlier than the read’s.

If it does, the scope is used. If not, the command fails with ExplicitScopeDoesNotCoverReads. A stream decision is an explicit scope, so a stream decision beside a whole-source read of the same source is refused, because the stream is narrower than the read. Refusals has the fixes.

An event source definition can declare which dimensions guard an append. Under On, Arc does not build the routing scope itself. It hands the declared dimensions to Chronicle, which merges them with the read at commit.

DefinitionUnder On
Declares nothingAdds nothing
Declares dimensions including the event source idMerged into the read’s guard. The declared dimensions are kept only where they equal the read’s, and the event types become any type
Declares dimensions without the event source idKept as a separate guard, not tied to the read. It does not protect the state you read

A definition that declares Concurrency = EventSourceId | EventStreamType makes the guard of a Withdraw command wider:

// without the definition's dimensions
guard acc-1: types=[MoneyDeposited, MoneyWithdrawn], after <tail before the read>
// with the definition's dimensions merged in
guard acc-1: any type, after <tail before the read>

Declare Concurrency only for ledgers, numbering and approval streams, where every writer should see every other. For anything else it only adds conflicts. See Chronicle: how guards are derived for the full merge rules and the notation.

DependencyGuard
Aggregate rootEnrolled when the aggregate is rehydrated: its source and the event types it handles. An aggregate and a plain read of the same source are refused
Stream decisionAn explicit scope enrolled when the stream is read: the routed stream, any event type
Fresh-identifier checkAn expected-empty guard on the new id
SituationWhat happens
A writer that decides without a guardIt can still break the rule. A guarded writer detects its event
Revise or Redact between the read and the appendNot detected. They do not move the tail. Chronicle refuses them inside a protected invocation
A projection definition changed in another processNot detected until this client registers again. Quiesce commands during a projection deploy
Other event sequences, external state, other databasesNot guarded
A command that appends nothingA guard that exists only because of a read sends nothing. No write means nothing to protect
Events that arrive after the commitNot part of the decision

Each guarded read makes two tail reads and a fold of the key’s history, and retries the fold when events arrive during it, up to three attempts. The first read of a model type in a process also checks the projection definition. Identical reads inside one command share one fold. The kernel serves the fold through a projection that is shared by every key of a model type, so heavy use of one model type is serialized across keys and tenants.

No benchmark for read protection is published yet. Keep guarded models flat and keys short-lived.