Skip to content

Read protection (.NET)

Use read protection when you own the code that begins and completes a unit of work, such as a command pipeline or a request host, and you want Chronicle to refuse a decision that is not guarded. Inside a protected invocation, a read either becomes a guard for the commit or is refused. If you only need to guard one append, a decision read passed to the append is enough. If the rule is uniqueness, use a constraint.

A decision read guards a decision only when you remember to use one. Nothing stops the next change from reading a read model through IReadModels, deciding on the value and appending, which guards nothing and fails silently when two requests race. Read protection turns that mistake into an error at the first call: inside the invocation, a plain read is refused unless you say it is advisory.

using Cratis.Chronicle;
using Cratis.Chronicle.ReadModels;
using Cratis.Chronicle.Transactions;
public static class ProtectedScreenData
{
// Uses ProtectedSeatReservations from the owner-commit example.
// Inside a protected invocation this plain read is refused with ReadRefusedWhileReadsAreProtected,
// because it would feed a decision without guarding it. Opening an advisory allowance says that
// a stale value is acceptable here, and the reason is required.
public static async Task<int> SeatsShownOnScreen(IEventStore eventStore, IUnitOfWork unitOfWork, ReadModelKey flightId)
{
using (unitOfWork.AllowReads(ReadAllowance.Advisory, "Shown on the booking screen. Not used for the decision."))
{
var view = await eventStore.ReadModels.GetInstanceById<ProtectedSeatReservations>(flightId);
return view?.Seats ?? 0;
}
}
}

View C# snippet source on GitHub

The safe form is the default, and the unsafe form needs a reason that stays in the code. Protection costs more than an unprotected read, and it refuses reads Chronicle cannot guard exactly. See Why protection is opt-in.

This is the Chronicle API that a host drives. Cratis Arc is such a host: when read protection is on for an Arc project, Arc begins the frame for each command and resolves read-model parameters through IDecisionReads.GetFor. How Arc uses this API maps Arc’s side to Chronicle’s. Read this page when you use Chronicle directly or write a host like Arc’s.

Inside a protected invocationResult
IDecisionReads.Get<T> and GetFor<T>Fold the model, enroll the read in the unit of work, then return the value
IDecisionReads.GetDetached<T>Refused with DetachedReadWhileReadsAreProtected
Raw reads (IReadModels, IReducers, event sequence reads)Refused with ReadRefusedWhileReadsAreProtected unless a read allowance is open
Immediate appends (Append, AppendMany, prepared and through-event-source variants)Refused with ImmediateAppendWhileReadsAreProtected. Stage events in the unit of work instead
Commit, Rollback and Dispose of the unitRefused unless the owner calls them
Events and reads after the unit completedRefused

Nothing changes outside a protected invocation, and nothing changes for code that never calls EnforceReadProtection().

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;
[EventType]
public record ProtectedSeatReserved(int Seats);
[Passive]
public record ProtectedSeatReservations(
string Id,
[AddFrom<ProtectedSeatReserved>(nameof(ProtectedSeatReserved.Seats))] int Seats);
public static class ProtectedReservation
{
public static async Task<bool> Reserve(IEventStore eventStore, EventSourceId flightId)
{
var unitOfWork = (UnitOfWork)eventStore.UnitOfWorkManager.Begin(CorrelationId.New());
using var frame = unitOfWork.EnforceReadProtection();
var owner = unitOfWork.ClaimDecisionReadCommitOwnership();
try
{
var reservations = await eventStore.GetDecisionReads()
.GetFor<ProtectedSeatReservations>(unitOfWork, flightId.Value);
if ((reservations.Instance?.Seats ?? 0) >= 180)
{
await unitOfWork.RollbackAsOwner(owner);
return false;
}
unitOfWork.AddEvent(EventSequenceId.Log, flightId, new ProtectedSeatReserved(1), Causation.Unknown());
await unitOfWork.CommitAsOwner(owner);
// A changed read is reported as a conflict: nothing was appended. Re-read and decide again.
return !unitOfWork.GetDecisionConflicts().Any() && unitOfWork.GetConcurrencyViolations().Count() == 0;
}
catch
{
if (!unitOfWork.IsCompleted) await unitOfWork.RollbackAsOwner(owner);
throw;
}
}
}

View C# snippet source on GitHub

IUnitOfWork.EnforceReadProtection() returns a ReadProtectionFrame. The frame is flow-local: it follows the asynchronous flow that created it and nothing else.

  • Owner and lease. The first call for a unit returns the owning frame. Later calls on the same unit return a lease. Disposing a lease does nothing. Only the owner ends enforcement, so dispose it in a finally block on success, failure and cancellation.
  • Begin first. Call it before any event or read is enrolled. Otherwise it throws ReadProtectionMustBeginBeforeEnrollment.
  • One unit. While a frame is active, enrollment, staging and completion must use the frame’s unit. Any other unit, including one set with UnitOfWorkManager.SetCurrent, is refused with ReadProtectionUnitMismatch.
  • After completion. ReadProtectionFrame.UnitCompleted reports whether the unit was committed or rolled back. The frame stays active until the owner disposes it. Protected reads and staging after completion are refused with ReadAfterProtectedCommit and UnitOfWorkIsCompleted.

A unit implementation that does not support this throws UnitOfWorkReadProtectionNotSupported.

A unit that enforces read protection is completed only by its owner. Claim ownership with ClaimDecisionReadCommitOwnership(), then use CommitAsOwner(owner) or RollbackAsOwner(owner). Calling Commit(), Rollback() or Dispose() on the unit throws ProtectedUnitOfWorkRequiresOwner, so an aggregate-style Commit() in application code cannot complete the unit half-guarded. A commit that is already running throws UnitOfWorkIsCompleting for a rollback.

A successful commit validates every guard atomically with the append. A changed read is a conflict, not an exception: nothing is appended, and GetDecisionConflicts() names the model type, the key and, when the kernel reports it, the first event that changed the read. Check GetConcurrencyViolations() and GetConstraintViolations() for the other outcomes.

With no staged events, the commit still validates a guard that was supplied for validation only. A guard that exists only because a read was enrolled sends nothing, because no write means nothing to protect.

Chronicle refuses CompleteStream inside a protected invocation, even after the commit. To close a stream once the commit succeeded, run it through the owner’s frame:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Transactions;
public static class ProtectedStreamClosing
{
public static Task CloseAfterCommit(ReadProtectionFrame frame, EventStreamType streamType, EventStreamId streamId) =>
frame.RunAfterCommit(sequence => sequence.CompleteStream(streamType, streamId));
}

View C# snippet source on GitHub

RunAfterCommit throws ImmediateAppendWhileReadsAreProtected if the unit did not commit successfully, or if the frame is no longer active. It permits stream completion on the unit’s own event sequence and nothing else: no read and no append.

IUnitOfWork.AllowReads(allowance, reason) opens a flow-local region for raw reads and returns an IDisposable. The reason must be a non-empty explanation; otherwise it throws ReadProtectionAllowanceNotValid. An allowance never permits an immediate append.

ReadAllowanceUse it forWhat you must do
AdvisoryA read that feeds a screen, a hint or a log, where a stale value is acceptableNothing. The read guards nothing. GetDetached is also permitted and returns an advisory read
ContributedA read that influences the decision and that you enroll yourselfEnroll it with EnrolContributedRead before you use the value

Allowances are not inherited by a reducer that Chronicle runs for a decision read. Code inside the reducer that reads Chronicle meets the guards.

A read region follows the asynchronous flow, so a region opened by an outer invocation also covers any invocation it runs. A host that runs a nested invocation under the same frame, such as a command that executes another command, calls IUnitOfWork.SuspendReadRegions() when the nested invocation starts and disposes the returned scope when it ends.

While the suspension is open, reads are refused as if no region were open. A region opened inside the suspension applies as usual. Disposing the suspension restores the inherited regions. Suspensions nest, and a suspension never ends read protection or removes an enrolled guard. Calling it from a unit that does not own the active invocation throws ReadProtectionUnitMismatch.

CallUnder a frame
IDecisionReads.Get<T>(key)Allowed. Enrolls the read in the ambient unit before it returns
IDecisionReads.GetFor<T>(unit, key)Allowed. Enrolls the read in the given unit, which must be the frame’s unit, before it returns. Hosts such as Arc use this
IDecisionReads.GetDetached<T>(key)Refused with DetachedReadWhileReadsAreProtected, unless an Advisory allowance is open

GetDetached exists to hand a read to a chosen append with guardedBy:. Enrolling it silently in the ambient unit would change which commit it guards, so a protected invocation refuses it. Stream-scoped overloads of Get, GetFor and GetDetached follow the same rules.

GetFor and Get also check, before the value is exposed, that no event staged in this unit would have changed the read. If one would, they throw ReadOfSourceWithStagedEvents: the read cannot see events that are staged but not committed. Decide once, then pass the value down.

IDecisionReads.Describe<T>() reports without I/O whether a model can be read, which event types it guards, and whether it uses Pinned generation delivery. See Decision reads.

For a model declaring [EventStream<TSource>(stream)], these same enrollment rules apply, but a key-only read is undefined even outside read protection. DecisionReadRefused.Reason is ReadModelRequiresStreamScope (24) for a key-only decision read or plain read. A decision read with another stream type or event source type, including an omitted source type, refuses with StreamScopeDoesNotMatchReadModel (25). Pass the declared route to Get or GetFor; use the stream-aware plain read only for advisory queries. See Read models scoped to one event stream.

Use a contributed read when a decision depends on events you read yourself, for example an aggregate rehydrated from one event source. Capture the boundary first, read, then enroll a ContributedRead before the value is used:

using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
using Cratis.Chronicle.Transactions;
[EventType]
public record ProtectedDeposit(decimal Amount);
public static class ProtectedAccountHistory
{
public static async Task<decimal> LoadBalance(IEventStore eventStore, IUnitOfWork unitOfWork, EventSourceId accountId)
{
var deposit = eventStore.EventTypes.GetEventTypeFor(typeof(ProtectedDeposit));
using (unitOfWork.AllowReads(ReadAllowance.Contributed, "Load the account history"))
{
// Capture the boundary before reading, then enroll before the value is used.
var boundary = await eventStore.EventLog.GetTailSequenceNumber();
var events = await eventStore.EventLog.GetForEventSourceIdAndEventTypes(accountId, [deposit]);
unitOfWork.EnrolContributedRead(new ContributedRead(
ConcurrencyScopeOrigin.Explicit,
accountId,
new ConcurrencyScope(boundary, accountId, EventTypes: [deposit]),
eventStore.Name,
eventStore.Namespace,
EventSequenceId.Log,
$"Account history for '{accountId}'"));
return events.Sum(_ => ((ProtectedDeposit)_.Content).Amount);
}
}
}

View C# snippet source on GitHub

The ConcurrencyScopeOrigin of the contribution is Explicit or Aggregate. Any other origin throws ContributedReadOriginNotValid. Enrollment is atomic and runs before the call returns:

  1. The target (event store, namespace and sequence) must match the unit, or it throws ReadDependencyTargetMismatch.
  2. No staged event may match the read’s source, types and dimensions, or it throws ReadOfSourceWithStagedEvents.
  3. Two different explicit or aggregate scopes on one label throw ConflictingContributionsOnLabel, or ConflictingExplicitScopes when a decision read is on that label too.
  4. An aggregate contribution and a decision read on the same event source throw ReadAndAggregateOnSameSource.
  5. A contribution without an event source throws SourceLessReadNotSupported.

IUnitOfWork.AddEventWithOptions(...) stages an event with EventEnrollmentOptions, including the Origin of its scope. The origin of an event may not be DecisionRead; that value is reserved for reads and throws ConcurrencyScopeOriginNotValidForEvents. EventEnrollmentOptions.DeclaredConcurrency and EventForEventSourceId.DeclaredConcurrency ask Chronicle to resolve the concurrency dimensions at commit instead of using a scope you computed.

At commit, Chronicle retains one condition per distinct enrolled predicate, with the earliest position for repeated reads of that predicate. Different streams and event-type filters keep independent conditions, even on the same source label; they are not broadened into one guard. The kernel must advertise concurrency-scope-conjunctions when a label has several conditions. IUnitOfWork.GetAppendConditions() returns the conditions of the last commit as AppendCondition values. Use it in tests and diagnostics to see exactly what a commit guarded.

AppendCondition memberMeaning
LabelThe event source the condition belongs to
EventTypes, EventSourceType, EventStreamType, EventStreamIdThe narrowing. Empty types or a missing dimension means any
AfterThe retained boundary. An event matching the condition after it is a conflict
ExpectsNoMatchingEventNo matching event may exist at all. This is how an absent model is guarded
OriginsWhich dependencies supplied the condition (AppendConditionOrigin)
ReadsDescriptions of the protected reads behind it

AppendConditionOrigin is ProtectedRead, Explicit, Aggregate, DeclaredRouting or DefaultRouting.

If the commit also carries explicit scopes, each read on that label must be covered by at least one explicit scope whose boundary was captured no later than the read. Otherwise the commit is refused with ExplicitScopeDoesNotCoverReads. A declared-routing condition that the explicit scope does not cover throws DeclaredRoutingNotCoveredByExplicitScope. Scope labels starting with $read-protection/ are reserved (ReservedConcurrencyScopeLabel). Custom concurrency strategies are not supported with read protection (ReadProtectionRequiresTheDefaultConcurrencyStrategy).

Inside a frame, these raw reads need a read allowance:

  • IReadModels: GetInstanceById, GetInstances, GetSnapshotsById, Observe, Watch, GetWatcherFor
  • IReducers: GetInstanceById, GetInstances
  • Materialized read models: GetInstances, ObserveInstances
  • Event sequences: HasEventsFor, GetMetadataAt, GetFromSequenceNumber, GetForEventSourceIdAndEventTypes, GetNextSequenceNumber, GetTailSequenceNumber, GetTailSequenceNumberForObserver, IsStreamCompleted, GetClosedStreams, VerifyContent
  • IProjections.Query (an ad-hoc projection over the event log)
  • IEventStore.GetNamespaces

Every public immediate append on an event sequence is refused regardless of allowances, and so are Revise, both Redact overloads and CompleteStream outside RunAfterCommit. Treat a Chronicle read that is not in this list as unguarded.

ExceptionWhen
DetachedReadWhileReadsAreProtectedGetDetached under a frame without an Advisory allowance
ReadRefusedWhileReadsAreProtectedA guarded raw read without an allowance
ImmediateAppendWhileReadsAreProtectedAn immediate append, Revise or Redact, or CompleteStream outside RunAfterCommit
ReadAfterProtectedCommitA protected read after the unit completed
ReadOfSourceWithStagedEventsA read of a source after events that match it were staged
ReadProtectionUnitMismatchEnrollment, staging or completion on a unit other than the frame’s
ReadProtectionMustBeginBeforeEnrollmentEnforceReadProtection() after anything was enrolled
ProtectedUnitOfWorkRequiresOwnerCommit, Rollback or Dispose by a caller that is not the owner
UnitOfWorkIsCompletedStaging after the commit began or after rollback
DecisionReadRefusedThe model cannot be guarded exactly. See Decision reads
ReadAndAggregateOnSameSource, SourceLessReadNotSupportedThe dependency combination is unsupported
ConflictingContributionsOnLabel, ConflictingExplicitScopesExplicit expectations contradict each other for one predicate, or mix no guard with a guard
ExplicitScopeDoesNotCoverReads, DeclaredRoutingNotCoveredByExplicitScopeAn explicit scope is narrower than what was read or declared

Each message names the fix. Chronicle refuses rather than guard something it cannot compute exactly.

  • History mutation. Revising or redacting an event does not advance the tail, so a read that was folded before the change is not invalidated. Inside a frame, Revise and Redact are refused; run them as separate administrative operations. Chronicle issue #4900 tracks a kernel check.
  • Reducers. A classic reducer is admitted only if it has no filters and no constructor dependencies other than ILogger<T>. A self-reducing read model is exempt from the dependency rule. See Decision reads.
  • Pinned delivery. A projection with Pinned generation delivery is refused with GenerationDeliveryNotSupported.
  • Source-less reads. A contributed read must name an event source. Reads over many sources are refused.
  • One event log. Guards apply to one event sequence, the event log, and one unit of work cannot span sequences.
  • Writers without a guard. A writer that decides without a guard can still break the rule. The rule holds only when every writer that decides on it is protected, or when it is a constraint.

If you come from an Arc command, this table maps what you declare there to what this page calls it. Arc’s own pages describe its settings and attributes. The Chronicle names are the stable API.

In an Arc commandIn the Chronicle API
Read protection is on for the projectThe host calls EnforceReadProtection() for each command and completes the unit as its owner
A read-model parameter that is guardedIDecisionReads.GetFor<T>(unit, key), enrolled in the command’s unit before the value is exposed
A read the command declares unprotected, with a reasonAllowReads(ReadAllowance.Advisory, reason) around the read. The read guards nothing
A read the host performs itself and then enrollsAllowReads(ReadAllowance.Contributed, reason), then EnrolContributedRead before the value is used
A read model that cannot be guarded exactlyDecisionReadRefused with a typed reason, such as a join, a filtered reducer, a classic reducer with constructor dependencies or a key that is not the event source ID
Events a command returnsStaged in the unit and appended by CommitAsOwner. An immediate append is refused with ImmediateAppendWhileReadsAreProtected
A nested command does not inherit its caller’s read allowancesSuspendReadRegions() when the nested command starts, disposed when it ends
A lost raceA conflict in GetDecisionConflicts(). Nothing was appended

The word “advisory” in this API and “unprotected” in Arc mean the same thing: you accept a stale value and nothing guards it. Chronicle keeps Advisory because it also has Contributed, a read that does guard.