---
title: Decision-consistent reads (.NET)
editUrl: https://github.com/Cratis/Chronicle/edit/main/Documentation/read-models/decision-reads.mdx
description: Guard an event-log append against changes to an event-source-keyed projection read for a decision.
---

import { Tabs, TabItem } from '@astrojs/starlight/components';


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.

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/read-models/decision-reads/detached-read.md)

</TabItem>
</Tabs>

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`):

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
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](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/read-models/decision-reads/early-commit.md)

</TabItem>
</Tabs>

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.

## Admitted projections

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.

## Limits and assumptions

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 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.

The kernel's duplicate-sequence collision retry renumbers an append without revalidating scopes. Duplicate grain activations, foreign writers **or unresolved earlier storage outcomes** can therefore defeat this and existing concurrency scopes. Quiesce such writers; a separate kernel follow-up must address atomic failed batches, numbering resynchronization and compatibility with legacy scopes before altering that retry path.
