Skip to content

Decision-consistent reads (.NET)

A decision read folds a projection from the event log in a fresh session and supplies an opaque guard for an append. If an event that could affect the read model is appended for the same source after the read boundary, the guarded append reports a concurrency violation and appends nothing. An absent model is guarded too: a concurrent creation conflicts. An unrelated event type or a change for another source does not invalidate the read. This is optimistic concurrency, not a retry of the command or a global lock.

Use a decision read when a rule depends on state you read first. For a unique value or a create-once rule, a constraint is simpler and cheaper. Choosing how a rule holds compares the options, Scenarios shows each as code, and the measured cost is in What a decision read costs. A plain read through IReadModels or an injected read model guards nothing, even when it is passive. A decision read is Chronicle’s form of a DCB decision model and append condition, within its one-event-source-per-event model: Dynamic Consistency Boundary in Chronicle says what maps and what does not, and DCB examples in Chronicle shows the canonical examples. To scope a read to one stream and append through an event source definition, see Event sources and the Dynamic Consistency Boundary.

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.ReadModels;
var read = await eventStore.GetDecisionReads().GetDetached<OrderEligibility>(orderId);
if (!read.Exists)
{
// Decide whether creation is allowed, including the absent case.
}
var result = await eventStore.EventLog.AppendMany(
[new EventForEventSourceId(orderId, new OrderPlaced())],
guardedBy: [read]);
var conflicts = result.GetDecisionConflicts([read]);
if (conflicts.Any())
{
// The decision must be read again and resubmitted; no sequence numbers are exposed.
}
// Check result.IsSuccess separately for constraint violations and other append failures.

View C# snippet source on GitHub

For a unit of work, Get<T>(key) enrolls into the ambient unit; GetDetached<T>(key) does not. Alternatively, enroll a detached read using IUnitOfWork.AddDecisionRead. A protected unit must be completed with its owner’s capability (UnitOfWork.ClaimDecisionReadCommitOwnership and CommitAsOwner or RollbackAsOwner); calling Commit() from application code after enrollment is rejected. Once a claimed unit has enrolled a decision read, public Rollback() and Dispose() on an open unit are also refused: only the owner can roll it back. During a pending commit, public and owner rollback instead throw UnitOfWorkIsCompleting in both Compatibility and Strict modes, retaining staged events; disposal remains a no-op. A claim alone does not change public rollback or disposal of a unit without enrolled decision reads, but owner rollback during a pending commit is also refused. A protected commit with no events still validates the guard. A kernel too old to allow validate-only returns DecisionReadValidateOnlyNotSupported, never success. Units without decision reads keep their previous staging behavior by default: late AddEvent and AddEvents calls still stage events that cannot be appended, but each attempt logs an error with the correlation id and no event content. Opt in to ChronicleOptions.UnitOfWorkLifecyclePolicy = UnitOfWorkLifecyclePolicy.Strict (also available through client and ASP.NET Core options) to reject staging as soon as commit begins, after commit failure, rollback or disposal. Strict rejection throws UnitOfWorkIsCompleted before enumerating input or changing staged events; it will become the default in the next major release. Protected units still refuse late events in compatibility mode. The Chronicle.AspNetCore middleware owns the request unit and normally completes it after the response has been written; a conflict is therefore reported too late to change that response. To respond to a protected decision’s outcome, an action can ask the middleware’s owner to commit early via HttpContext.Features.Get<IUnitOfWorkCompletionFeature>() (in Cratis.Chronicle.AspNetCore.Transactions):

using Cratis.Chronicle.AspNetCore.Transactions;
using Microsoft.AspNetCore.Http;
var completed = await HttpContext.Features.Get<IUnitOfWorkCompletionFeature>()!.CommitAsync();
if (completed.GetDecisionConflicts().Any())
{
return Conflict(); // Re-read and retry the decision in a new request.
}
if (!completed.IsSuccess)
{
// Handle constraint violations and other append failures before writing the response.
}

View C# snippet source on GitHub

The middleware will not commit the completed unit again. IUnitOfWork.GetDecisionConflicts() maps violated labels to the read model type and key without exposing boundaries. IUnitOfWork.HasEnrolledDecisionReads is a read-only indication that at least one decision read was successfully enrolled, including through a direct AddDecisionRead call. On Chronicle’s unit of work it remains true after commit, rollback or disposal. Custom implementations default to false for compatibility; a false value on one of those implementations is not proof that it cannot enroll protected reads. This property does not grant permission to commit a protected unit without its owner capability.

Only a single Chronicle projection on the event log, keyed directly by the event source ID, with a nonempty finite set of event types (including removal types), can be protected. Empty keys, *, #, leading/trailing whitespace and event type IDs containing commas are refused. Routing keys must be empty or $eventSourceId; joins (including variants), children, nested projections, derivatives, event-property routing, all-event subscriptions, reducers and projections on other sequences are refused. String keys without a converting format are admitted. GUID keys, including GUID concepts, require lowercase canonical D form. All writers of event source IDs for GUID-keyed models must use this same canonical form; non-.NET writers using alternate casing or formatting are outside this guarantee even if the requested key is canonical. Numeric and other converting key types, and models without a key property, are refused. IDecisionReads.Admit<T>() checks the projection shape without I/O; Get also validates the key. Unsafe shapes and keys throw DecisionReadRefused with a typed reason before reading.

The client captures the unfiltered event-log boundary before folding and uses it for the guard. The filtered tail and a first-use client/kernel definition comparison are diagnostics, not certificates of executing-projection agreement. An incomplete fold detected by the probe refuses with FoldIncomplete; a listed-definition disagreement refuses with DefinitionMismatch. The executing projection definition and initialization state must agree with the client’s definition. Deployments changing definitions, including cross-silo cache convergence, must quiesce protected commands. Multiple reads of one key merge their event types and use their earliest boundary; an explicit competing scope on that key is refused, while the decision scope replaces an ordinary append-time default scope. Targets must match the event store, namespace and event-log sequence.

When a decision concerns one stream within an event source, pass the source ID, stream type and stream ID to Get<T> or GetDetached<T>. The fold and its concurrency guard use the same stream filters; events on another stream do not invalidate the read. An optional event source type narrows both further.

using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.ReadModels;
[EventType]
public record StreamOrderPlaced(string Status);
[Passive]
[FromEvent<StreamOrderPlaced>]
public record StreamOrderEligibility(string Id, string Status);
public static class StreamDecisionExample
{
public static async Task<AppendManyResult> PlaceOrder(
IEventStore eventStore, EventSourceId orderId)
{
var read = await eventStore.GetDecisionReads().GetDetached<StreamOrderEligibility>(
orderId, eventStreamType: "orders", eventStreamId: "checkout");
// Make the decision using read.Instance, including the absent case.
return await eventStore.EventLog.AppendMany(
[new EventForEventSourceId(orderId, new StreamOrderPlaced("placed"))
{
EventStreamType = "orders",
EventStreamId = "checkout"
}],
guardedBy: [read]);
// The caller checks IsSuccess and handles conflicts before resubmitting.
}
}

View C# snippet source on GitHub

The stream type and stream id are strings here. With an event source definition you can take them from the definition, so the read and the append use the same values. See Event sources and the Dynamic Consistency Boundary.

AdmitForStream<T>() admits the same finite, flat event-log projections as Admit<T>(), and also projections consistently keyed by $eventContext(eventStreamId) across their creation, update and removal events. A source-keyed projection keeps the source ID as its model key, but folds only the selected stream. A stream-keyed projection uses the stream ID as its model key; the concurrency label remains the source ID. For a GUID-keyed stream projection, use lowercase canonical D form for stream IDs on every append and read, just as source-keyed GUID projections require it for source IDs. Reading a stream-keyed projection without stream scope refuses with StreamKeyRequiresStreamScope. The kernel must explicitly acknowledge stream scoping; an older kernel refuses with StreamScopeNotSupported rather than silently folding the whole source. Reducers remain refused with Reducer; protected reducer reads require a separate follow-up.

The stream-scoped overload requires a specific stream type: EventStreamType.All refuses with AllStreamsNotSupported. EventStreamId.Default is allowed for event-source-keyed projections with a specific stream type, including stream types that use only the default ID. As in other Chronicle event filters, this default does not narrow by stream ID: the fold and guard cover every ID within that source and stream type. Stream-keyed projections require an explicit stream ID; the default refuses with StreamKeyRequiresExplicitStreamId because it could fold several instance keys together.

Multiple reads for the same source still merge at their earliest boundary and union their event types. If their stream type or stream ID differs, the merged guard drops both stream dimensions, checking all streams of that source. Different source type filters likewise widen to all source types. This can produce extra conflicts, but cannot miss a change affecting either read.

A materialized projection keyed only by stream ID can collide when two event sources use the same stream ID. Prefer a passive projection ([Passive]) for these decision folds, or use a composite key for materialization. Composite-key projections are not admitted for protected decision reads. Command-property binding belongs to Arc; Chronicle supplies the explicit read API.

The guarantee assumes a single ordered event-log writer, ordered primary storage reads, stable projection execution, and literal identity between source IDs and keys. Revise and redact do not advance the tail; migration generation replacement can bypass the grain; neither is guarded. Other event sequences, external data, clock values and definition changes are not guarded. A guard detects every matching event appended after its boundary, whoever wrote it. It only protects decisions that carry a guard: a writer that decides without one can still break the rule. The rule holds only when every writer that decides on it is guarded, or when it is a constraint, which the kernel checks for every append. A removal followed by recreation with nonempty defaults can fold differently from materialization even though the append guard still works. Each attempt makes two tail RPCs, activates a fresh projection session, folds the key’s entire history and requests session dehydrate. A read may take up to three attempts. The first read of each model type also makes two definition-listing RPCs. Its cost grows with that history. The minimum supported kernel is Chronicle 19.0.0 with ExpectsNoMatchingEvent scope validation (introduced in 16.34); older kernels can silently skip an empty-log guard.

When the kernel renumbers an append after a duplicate-sequence collision, it validates the scopes again before writing, as of Chronicle 19.40.1, so a decision read’s guard still applies on that path. Constraints are not run again there. Older kernels renumbered without validating again, so on those a duplicate grain activation or an unresolved earlier storage outcome could defeat this and existing concurrency scopes. Quiesce such writers when you must support a kernel older than 19.40.1. Writes that bypass the kernel’s append path are never guarded.