Skip to content

The same guard without Arc: decision reads and append conditions · Decisions that hold, Part 8

Decisions that hold · Part 08 of 11

Parts 2 to 7 let Arc do the work. A command takes a read model, read protection is On, and the read guards the command’s append without any code that mentions a guard. Those examples assume read protection is On, as in new Cratis applications (see Part 2).

Not every decision runs as an Arc command. A minimal API endpoint, an MVC controller, a background worker or an Elixir service talks to Chronicle directly, and there nothing enrolls a read for you. The mechanism underneath Arc is a Chronicle API, and you can call it yourself. This part does that, and then uses the result to say concretely what Chronicle’s guard is in the terms of the Dynamic Consistency Boundary (DCB), and what it is not.

Arc’s read protection is a host for Chronicle’s decision reads. For each command under On, it does three things:

In an Arc command under On In Chronicle’s API
A read-model parameter is read fresh IDecisionReads.GetFor<T>(unit, key): note the log position, fold the key, enroll the read as a guard in the command’s unit of work
The events Handle() returns are written Staged in the unit and appended with every enrolled guard in one call
A lost race comes back as a 400 with ReadChanged The unit’s commit fails, and GetDecisionConflicts() names the read model and key

Without Arc, those three are yours: take the read as a decision read, attach it to the append, and check the result before you answer the caller. Chronicle’s read protection page has the full mapping.

The smallest form guards one append. A workshop has a fixed number of places, and a booking may only be made while one is free. Decision reads exist in the .NET and Elixir clients:

using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.ReadModels;
public record WorkshopId(string Value) : EventSourceId(Value);
[EventType]
public record WorkshopOpened(int Places);
[EventType]
public record PlaceBooked(string Attendee);
[EventType]
public record PlaceCancelled(string Attendee);
// The decision model: a flat projection with an Id, keyed by the event source.
[FromEvent<WorkshopOpened>]
public record WorkshopPlaces(
string Id,
[SetFrom<WorkshopOpened>(nameof(WorkshopOpened.Places))] int Total,
[Increment<PlaceBooked>] [Decrement<PlaceCancelled>] int Booked);
public static class BookPlace
{
public static async Task<bool> Handle(IEventStore eventStore, WorkshopId workshop, string attendee)
{
// 1. The read notes the log position, then folds the workshop's events.
var read = await eventStore.GetDecisionReads().GetDetached<WorkshopPlaces>(new ReadModelKey(workshop.Value));
if (read.Instance is not { } places || places.Booked >= places.Total)
{
return false;
}
// 2. The append carries the read as its guard. A later PlaceBooked, PlaceCancelled or
// WorkshopOpened for this workshop rejects it and nothing is written.
var result = await eventStore.EventLog.AppendMany(
[new EventForEventSourceId(workshop, new PlaceBooked(attendee))],
guardedBy: [read]);
return result.IsSuccess;
}
}

GetDetached does two things in order. It notes the position of the event log’s tail, and then it folds WorkshopPlaces for that workshop from the event log in a fresh projection session. It does not use the stored read model, which can lag and has no position. What comes back is the state (read.Instance) and the guard (read itself), and guardedBy: [read] hands the guard to the append. The kernel then rejects the append if an event of a type the model folds (WorkshopOpened, PlaceBooked or PlaceCancelled) was appended for this workshop after the noted position.

Four numbered steps from top to bottom: note the log position, fold ws-1 as the decision model, decide that Booked is less than Total, append with the condition. The condition for ws-1 lists WorkshopOpened, PlaceBooked and PlaceCancelled after the position from step one. Below it, two outcomes: a PlaceBooked for ws-1 arriving after step one rejects the append and nothing is written; a PlaceBooked for ws-2 does not, because another key is not watched.

The fold is what you decide on. The key, the model’s event types and the noted position are what the append is checked against.

Three details matter when you write this yourself.

  • An absent model is guarded too. If nothing has opened the workshop, Instance is null and the guard expects no matching event at all, so a WorkshopOpened that lands before the append rejects it. The example returns false in that case anyway, but a create-if-absent rule depends on it.
  • A conflict is a result, not an exception. AppendMany returns an AppendManyResult with IsSuccess set to false, and result.GetDecisionConflicts([read]) returns one conflict that names WorkshopPlaces and the key. IsSuccess is also false for a constraint violation, so ask for the conflicts when you need to tell the two apart.
  • Nothing is retried. Chronicle does not re-run your decision. The fold itself makes up to three attempts if events arrive while it folds, but once you have the value, a lost race means: read again, decide again, or tell the caller.

Events on other keys, or events of a type the model does not fold, such as a hypothetical WorkshopRenamed, leave the append alone. The guard is as narrow as the model.

The Elixir client reads inside Chronicle.decide. DecisionReads.get enrolls the read in the unit of work that decide opens, EventLog.append buffers the event in that unit, and the unit commits with the guard when the function returns. A lost race comes back as {:error, %DecisionConflict{}}.

GetDetached suits code where the read and the append sit next to each other. In a web request they often don’t: the read happens in one service, the append in another. In the .NET client, IDecisionReads.Get<T> enrolls the read in the request’s unit of work instead of returning a detached guard, and every event staged in that unit is appended with the guard when it commits.

A member may hold at most three loans. As a minimal API endpoint, with the loan count as the decision model:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record LoanTaken(string Isbn);
public record MemberLoans(
EventSourceId Id,
[Increment<LoanTaken>] int Active);
using Cratis.Chronicle.AspNetCore.Transactions;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.ReadModels;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
public record TakeLoanRequest(string Isbn);
public static class LoanEndpoints
{
public static void Map(WebApplication app) =>
app.MapPost("/members/{memberId}/loans", async (
string memberId,
TakeLoanRequest body,
HttpContext http,
IDecisionReads reads,
IEventLog eventLog) =>
{
var loans = await reads.Get<MemberLoans>(memberId);
if (loans.Instance is { Active: >= 3 })
{
return Results.UnprocessableEntity("The member has reached the loan limit.");
}
await eventLog.Transactional.Append(memberId, new LoanTaken(body.Isbn));
var unit = await http.Features.Get<IUnitOfWorkCompletionFeature>()!.CommitAsync();
if (unit.GetDecisionConflicts().Any())
{
return Results.Conflict("The member's loans changed. Nothing was written; read again and decide again.");
}
return unit.IsSuccess ? Results.Ok() : Results.UnprocessableEntity("The loan was not recorded.");
});
}

Chronicle’s ASP.NET Core middleware begins a unit of work for each request, and Get<T> needs that ambient unit. Transactional.Append stages the event in it. The line that is easy to leave out is the commit. Left alone, the middleware commits the unit after the endpoint has returned, when the response is already on its way, and it reports no concurrency violation to the caller. The caller would see a 200 for a write that was rejected. IUnitOfWorkCompletionFeature.CommitAsync() asks the middleware’s owner to commit now, so the endpoint can answer with the outcome, and the middleware does not commit that unit again.

When another loan for the same member lands between the read and the commit, this endpoint answers 409, and the member’s log has the competing loan and not this one. A member who already holds three gets the 422 before anything is staged. The conflict check comes before IsSuccess because IsSuccess is also false for a constraint violation. And the 409 carries a message, not the conflicts themselves: in Chronicle 19.73 and earlier, a DecisionConflict holds the read model’s Type, which System.Text.Json refuses to serialize, so Results.Conflict(unit.GetDecisionConflicts()) fails while it writes the response, on exactly the path you wanted to report. Map conflicts to your own response shape.

One mistake undoes it quietly: a plain eventLog.Append(...) in the same request is written immediately and bypasses the guard. A controller action uses the same calls, with HttpContext.Features instead of the http parameter; Arc’s Chronicle used directly page has both. In Elixir, Chronicle.decide commits when its function returns, so the outcome is known while you can still choose the response.

DCB, as the specification describes it, gives every event a set of tags. A decision builds its state, the decision model, from a query: event types (any of them) and tags (all of them). When it appends, it passes an append condition: fail if an event matching that query was stored after the position the decision read up to. One event can carry several tags, so a fact about a student and a course is one event that both decisions see.

Chronicle has the same two parts, a decision model and an append condition, but not the tags. Each event belongs to exactly one event source. The role of the tags is played by four values on every event, each with exactly one value: the event source ID, the event source type, the event stream type and the event stream ID. For the workshop read above, the query is “WorkshopOpened or PlaceBooked or PlaceCancelled, with event source ID ws-1”, and the position is the one noted before the fold.

DCB Chronicle
Decision model The state of a decision read: a flat projection keyed by the event source, over a finite set of event types
Query: event types ORed, tags ANDed The read model’s event types, and the event source ID plus, where you narrow it, event source type, stream type and stream ID
after: the position the decision read up to The position the decision read notes before it folds
Append condition guardedBy: on AppendMany, or the reads enrolled in a unit of work
Atomic append across entities AppendMany, all or nothing across event sources. A unit of work commits as one AppendMany

What DCB can do and Chronicle cannot follows from the one-source-per-event model. One event can’t be tagged with two entities, so a fact about two entities is two events in one atomic AppendMany, one per event source. There is no query for “events about both X and Y”. Custom tags are stored and filterable, but take no part in concurrency, so anything the guard must tell apart has to be an event type or one of the four values. Two independent conditions on the same event source are merged into one wider one. A decision read refuses a projection that joins entities or is keyed by something other than the event source. And a guard covers the event log only, not other event sequences.

Chronicle’s own summary is the sentence I’d keep: it supports DCB-style decision models and append conditions within its event-source model. It is not a DCB event store. Einar’s DCB, but not DCB argues for naming what you want from DCB instead. The DCB in Chronicle page has the full mapping and says when recording one fact as two events is acceptable, and DCB examples in Chronicle answers each canonical DCB example, including the course subscription with two decision reads and two events.

Some decisions don’t fit an admitted read model: a balance summed from two event types with no projection for it, or a rule over raw events. You can build the same guard by hand. Note the tail first, read the facts after, and append with a concurrency scope from the noted tail. In the .NET client:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
using Microsoft.AspNetCore.Http;
using ScopeBuilder = Cratis.Chronicle.EventSequences.Concurrency.ConcurrencyScopeBuilder;
[EventType]
public record Deposited(decimal Amount);
[EventType]
public record Withdrawn(decimal Amount);
public static class Withdraw
{
public static async Task<IResult> Handle(IEventLog eventLog, EventSourceId accountId, decimal amount)
{
var tail = await eventLog.GetTailSequenceNumber(accountId);
var events = await eventLog.GetFromSequenceNumber(EventSequenceNumber.First, accountId);
var balance = events.Select(_ => _.Content).Sum(content => content switch
{
Deposited deposited => deposited.Amount,
Withdrawn withdrawn => -withdrawn.Amount,
_ => 0m
});
if (balance < amount)
{
return Results.UnprocessableEntity("Insufficient funds.");
}
var scope = tail.IsUnavailable
? new ScopeBuilder().WithEventSourceId(accountId).ExpectingNoMatchingEvent().Build()
: new ConcurrencyScope(tail, EventSourceId: accountId);
var result = await eventLog.Append(accountId, new Withdrawn(amount), concurrencyScope: scope);
return result.IsSuccess ? Results.Ok() : Results.Conflict();
}
}

The order is the guard. With the tail noted before the read, a withdrawal that lands in between is after the scope’s position and rejects the append. Note the tail after the read instead, and that same withdrawal sits before the position: the append succeeds against a balance that no longer holds. When the account has no events yet, the tail is unavailable, and the scope expects no matching event at all.

This scope is wider than a decision read’s guard: it names no event types, so any event for the account after the tail rejects the append. That is safe, and more conflicts than you need on a busy account. Name the types with WithEventType<T>() on the builder when only some events matter. The alias at the top is there because in an Arc project Cratis.Arc.Chronicle.Commands has a static class called ConcurrencyScopeBuilder as well. Part 7 shows a concurrency scope in every Chronicle client.

Outside an Arc command, nothing reads the result for you, and every failure on this path is a value.

  • Append returns an AppendResult and AppendMany an AppendManyResult. A concurrency or constraint violation sets IsSuccess to false and throws nothing, so code that ignores the result reports success whatever happened.
  • GetDecisionConflicts tells a lost race from a constraint violation and names the read model and key. It does not name the event that conflicted.
  • A scope you build yourself without a position fails closed. If it narrows by event source, stream, source type or event types, the client turns it into “no matching event may exist”, so the append is rejected if anything matching is already in the log. Only a scope that narrows nothing is sent unchecked, with a client warning, and ConcurrencyCheckPerformed on the result says whether a scope was compared at all, so a skipped check does not look like a passing one. ConcurrencyOptions.SkipIncompleteExplicitScopes = true brings back the older skip. Give a scope the tail you noted, or say ExpectingNoMatchingEvent() when that is what you mean, as Withdraw does.
  • The first-append skip belongs to the default check. When Chronicle’s concurrency strategy issues the scope and nothing matching has been appended yet, there is no tail to compare against, and the check is skipped unless CheckFirstAppendIntoAScope is on. A decision read of an absent model already expects no matching event.

A decision read guards less than it might seem to, and costs more than a plain read.

  • Only some models can be read this way. A flat projection keyed by the event source, or an unfiltered reducer without constructor dependencies other than ILogger<T>. Joins, children, event-property keys, filtered reducers and other event sequences are refused with DecisionReadRefused and a typed reason, before anything is read.
  • GUID keys must be written the same way everywhere. For a model keyed by a GUID, every writer of that event source must use the lowercase canonical form. A writer in another language that formats it differently is outside the guard.
  • It is a full fold every time. Each attempt makes two tail reads, opens a fresh projection session and folds the key’s whole history. The first read of a model type also checks its definition. Keep the keys short-lived. Chronicle’s docs publish one measurement with its environment.
  • What it does not see. A revision or redaction of an earlier event does not move the tail, so it does not conflict. Other event sequences, external data, clocks and projection definitions changed by another process are not guarded either. And a guard only protects the decisions that carry it: a writer that appends 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.

Everything here is opt-in, append by append. That is the trade: code that calls Chronicle directly chooses each guard, and nothing stops the next change from reading through IReadModels and appending without one. Chronicle’s EnforceReadProtection() turns that around for a unit of work: inside it, a decision read is enrolled as a guard, a plain read is refused unless you mark it advisory with a reason, and an immediate append is refused. That is the contract Arc enforces for commands under On. Part 10 covers that, and how guards from several reads are merged. Part 9 is first: testing a decision against a competing write.

Chronicle’s decision reads page covers the unit-of-work lifecycle, stream-scoped reads and admitted shapes in full. Choosing how a rule holds compares decision reads with constraints and scopes, and Arc’s pitfalls page lists the results to check when you call Chronicle directly.

The .NET examples need Cratis.Chronicle and Cratis.Chronicle.AspNetCore 19.68.0 or later (decision reads themselves arrived in 19.9.0; the scope behaviour described here in 19.68.0), and the Elixir example needs the Elixir client 3.13.0 or later. Decision reads need a Chronicle server of 19.0.0 or later; on servers older than 19.40.1, an append renumbered after a duplicate-sequence collision is not checked again.