Skip to content

Choosing how a command's rule holds

Use this page when a command’s outcome depends on state: a stock level, a unique value, a capacity, a document that someone else may have edited. It tells you which mechanism makes the rule hold when two commands run at the same time, which mechanisms only look as if they do, and what each one costs.

Reading state in a validator or in Handle() never makes a rule hold. An injected read model, or a call to IReadModels, is a plain read: it returns a snapshot, captures no boundary and guards nothing. By default Arc builds the concurrency scope after Handle() returns, at the moment the command’s events are committed. That check protects only the short window between that late tail read and the write. It does not bind your decision to the state you decided on.

To make a rule hold, use the first row of this list that fits:

  1. Uniqueness or create-once: a Chronicle constraint. The kernel enforces it for every writer.
  2. A rule over the state of one or more event sources: mark the command [ProtectedDecision] and take a DecisionRead<T> (or IDecisionReads for other keys).
  3. A rule over one routed stream of raw events: a stream decision with IStreamReads, on a command routed through an event source definition or string routing attributes.
  4. Rich behaviour on one entity: an aggregate root, with its result returned from Commit().
  5. The caller supplies the revision it saw: an explicit scope with EventsWithConcurrencyScopes.
  6. A rare violation is acceptable: the default append, and a reactor that repairs the result.
Rule shapeUseAlternativesNever rely onToo much
Unique value (email, user name)Constraint: [Unique] on an event property, or IConstraintUse the value as the event source id and append with an expected-empty scope (Chronicle used directly)A validator or read model that checks “does it exist” firstA protected decision only for uniqueness
Created once (one library card per member)[Unique] on the event classAn aggregate: a new aggregate expects an empty historyAn Exists check in a validatorA decision read over the whole history
Rule over one key’s history (no loan while suspended)Protected decision on that keyAn aggregate; a stream decision if the facts live in one routed streamAn injected read modelA stream decision over a long stream
Stock or count on one keyProtected decision with a counting model-bound projectionAn aggregateA validator reading the countA constraint: it cannot count
Rule spanning two keys (course capacity and student limit)Protected decision with IDecisionReads for both keys and one event per key in one batchExplicit scopes in the same batch (Chronicle used directly)Two aggregates, each guarding only its own sourceOne model that tries to fold both keys: decision reads refuse it
Editing conflict (two people edit one document)Explicit scope carrying the revision the client sawA protected decision when the edit does not depend on what the client saw; an aggregateconcurrency: true alone: it reads the tail after Handle()A protected decision, which folds state to detect what a revision number already tells you
One stream, for example approvals per periodStream decisionA protected decision if the projection is admittedReading the stream with an injected read modelA protected decision that folds the whole key on every command
Rich behaviour (many invariants, state transitions)Aggregate rootOne protected decision per ruleMixing an aggregate with [ProtectedDecision]: it throwsA protected decision for each invariant when one aggregate carries them
A rare violation is acceptable (overbooking is fixed afterwards)Default append and a reactor that repairsconcurrency: true to narrow the default checkAssuming the repair always runs before harm is doneAny guard
UX hint (show “no copies left” before submit)A query or an advisory validator over a plain readNone neededTreating the hint as the ruleA decision read
External data (a price, a tax rate)Provide() fetches it; a decision read guards your own stateNoneAssuming the external value is guarded: it is notGuarding state you do not decide from
Hot key with a long historyKeep the state small: key by period, or use a constraintAn aggregate (it rehydrates the key’s handled events on every command)A decision read per command on a key that never stops growingA flat model over years of events

“Never rely on” means the mechanism can fail silently: the command succeeds and the rule is broken. Where guarantees do not hold lists which failures are silent and which are loud.

yes

no

no, only the shape of the input

yes

yes

no

yes

no

yes

no

yes

no

Is the rule that a value or an event happens once?

Constraint

Does the rule need state to decide?

Validator without dependencies, or data annotations

Is a rare violation acceptable and repairable?

Default append and a reactor that repairs

Does the caller supply the revision it saw?

Explicit scope: EventsWithConcurrencyScopes

Do the facts live in one routed stream of raw events?

Stream decision: IStreamReads

Is it rich behaviour on one entity?

Aggregate root

Protected decision: DecisionRead or IDecisionReads

A protected decision is the default shape for a stateful rule on event-sourced state. You mark the command, take the state as a DecisionRead<T>, and return a Result that declines or an event that records the decision:

using Cratis.Arc.Chronicle.ReadModels;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Arc.Validation;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.ReadModels;
using Cratis.Concepts;
using Cratis.Monads;
public record MemberId(Guid Value) : ConceptAs<Guid>(Value);
[EventType]
public record CopyAdded();
[EventType]
public record BookBorrowed(MemberId MemberId);
[EventType]
public record BookReturned();
public record BookStock(
EventSourceId Id,
[Increment<CopyAdded>, Decrement<BookBorrowed>, Increment<BookReturned>] int Available);
[Command]
[ProtectedDecision]
public record BorrowBook(EventSourceId BookId, MemberId MemberId)
{
public Result<BookBorrowed, ValidationResult> Handle(DecisionRead<BookStock> stock) =>
stock.Instance is not { Available: > 0 }
? ValidationResult.Error("No copies of this book are available.")
: new BookBorrowed(MemberId);
}

What happens when this command runs:

  1. Arc begins the command’s Chronicle unit of work and claims ownership of it.
  2. Before Handle(), Chronicle captures the log’s tail, then folds BookStock from the book’s own events. The boundary is captured before the fold, so any event that lands during or after the fold is detected.
  3. Handle() decides from stock.Instance. Result<TEvent, ValidationResult> declines the command before anything is appended. It adds no guard of its own; the guard is the decision read.
  4. After a successful Handle(), Arc commits as owner. Chronicle appends the events and rejects the append if any event of the types BookStock is built from, on that book, arrived after the boundary.
  5. On a conflict nothing is written. The caller receives a validation result with the reason concurrencyViolation and a message that the model “changed after it was read”. Arc does not retry the command; the caller reads again and resubmits.

The book’s id is the command’s event source id and therefore the decision read’s key. BookStock needs a property named Id of a string-like or Guid type: the key is matched to it. A model without one is refused when the command runs, with DecisionReadCouldNotBeAcquired, which surfaces as an error response, not as a build failure.

Only the facts that BookStock folds are guarded. Every CopyAdded, BookBorrowed and BookReturned must be appended to the book’s event source, or the model never sees them. See pitfalls.

Tests that exercise a protected decision use CommandScenario<T>().UseDecisionReads() with real seeded events and AppendConcurrently(...). See protected decision scenarios.

An event source definition is how a command names the event source and stream it appends to, and with [EventSource<TSource>(stream)] it gives the append a typed concurrency boundary: the event source id narrowed by the dimensions the definition declares. That boundary is the default append check, so a definition alone is not a row in the matrix above. It is the routing a stream decision reads from and the place a completed stream is declared. Scenarios for each stream shape and how routing works show the whole path.

MechanismGuards the rule?Protects every writer’s decision?Boundary capturedCost of one command
ConstraintUniqueness and create-once only; it cannot count or sumYes: the kernel checks every appendNone neededAn index check inside the append
Protected decision (DecisionRead<T>, IDecisionReads)Yes, for the folded event types on the read keysOnly decisions that carry a guard; it still detects any writer’s matching eventBefore the foldA fresh fold of the key’s history on every read
Stream decision (IStreamReads)Yes, for any event in the routed streamOnly decisions that carry a guard; it still detects any writer’s matching eventBefore reading the stream, in Provide()The whole stream crosses the wire per command
Aggregate rootYes, for the event types it handlesOnly decisions that carry a guard; it still detects any writer’s matching eventWhen the aggregate is resolvedThe handled events cross the wire per command; no snapshots
Explicit scope (EventsWithConcurrencyScopes)Yes, as strong as where you captured the revisionOnly decisions that carry a guard; it still detects any writer’s matching eventWherever your code captured itOne filtered tail query at commit
Default appendNo: it checks the window after Handle() onlyNoAt commit, after Handle()One tail query per event source
Command validatorNo: it is shape validation, see validatorsNoNoneNone
Plain read: materialized projectionNo: it comes from the sink and can lagNoNoneOne lookup
Plain read: passive projection or reducerNo: it folds fresh at read time but captures nothingNoNoneA fold of the key’s history on the first read
Reading the database directlyNo: it bypasses Chronicle and is eventually consistentNoNoneOne query
Reactor repairNo: a later compensating event, not a guardNoNoneAn extra event and its observer

Self-reducing read models (Chronicle 19.58.0 and later) behave like reducers here: they are plain reads, and decision reads refuse them.

The Dynamic Consistency Boundary (DCB) is an approach to event-sourced consistency: a command reads the events it needs, remembers the position of the last event it knew, and appends only if no matching event has arrived since. See dcb.events for the specification and Dynamic Consistency Boundary in Chronicle for how Chronicle maps it.

Chronicle expresses the DCB append condition. A concurrency scope plays the part of one query item with a position, a list of scopes acts as an OR, and an append of several event sources is atomic. A decision read is the Chronicle API that captures the position before it reads. A protected decision is how Arc opts a command in.

What Chronicle adds to DCB:

  • Constraints, which enforce uniqueness without a read.
  • Admission checks: a decision read refuses projection shapes that cannot be guarded exactly, instead of guarding them approximately.
  • A retry of the fold when events arrive during it, and a refusal if it cannot settle.
  • A mapping from a conflict back to the read model and key that conflicted.

What DCB can do that Chronicle cannot:

  • Match events by arbitrary tags. Chronicle stores tags, but tags take no part in consistency: a scope narrows by event source id, event source type, stream type, stream id and event types.
  • Give one event several tags, so a fact about two entities is a single event. A Chronicle event has exactly one event source id, so such a fact is written as one event per source, in one atomic append.
  • Combine several query items for the same key in one condition. Chronicle merges them into one scope: wider but safe.
  • Fold a decision model across entities or by property. Decision reads refuse joins, children and by-property keys.

See Choosing how a rule holds in Chronicle for the same decision from Chronicle’s side, and scenarios for Chronicle-only examples.

A decision read is a fresh fold of the key’s history. Chronicle does not take the materialized state, because that state can lag and has no boundary. The cost is therefore linear in the number of events of the model’s types for that key, on every read.

One decision read makes about four calls per attempt (two tail reads in parallel, the fold, and a fire-and-forget cleanup), and up to three attempts if events arrive during the fold. The first read of a model type in a process makes two more calls to check the projection definition. The kernel serves the fold through a projection grain shared by every key of that model type, so heavy use of one model type serialises across keys and tenants.

The only measurement published for decision reads is one scratch run. Treat it as an order of magnitude, not a benchmark:

Operation (median, ms)10 events for the key100 events for the key
Materialized read2.15.3
Passive read, warm3.78.7
Passive read, cold fold8.113.9
Decision read (detached)12.722.6
Aggregate-style read: tail and events5.319.2
Append of one event, no scope15.433.1
Append with an aggregate scope11.823.6

The run used the Chronicle development container 19.54.0 with the 19.54.0 client, on an Apple M4 Pro host with a load average of 80 to 100, 20 other event sources with 500 events each, and 20 samples per cell. A decision read cost about 1.6 to 2 times a cold passive fold and 4 to 6 times a materialized read. The guarded commit added no overhead that stood out from the noise; the append rows vary more between N=10 and N=100 than their content explains, so do not read a trend into them.

N=1,000 and N=10,000 were not measured. The growth with history is inferred from how the fold works: it starts at the first event of the key every time. Concurrent contention, SQL storage and the effect of the shared projection grain under load were not measured either.

MechanismRound tripsGrows withNote
Materialized read1NothingEventually consistent
Decision readAbout 4 per attempt, up to 3 attemptsThe key’s history of the model’s types, on every readIdentical reads in one command invocation share one fold
Stream decisionSame shapeThe whole stream, which crosses the wireNo type narrowing
Aggregate2 to 3The key’s handled events, on every commandNo snapshots; the fold runs in your process
ConstraintNone extraNothingChecked inside the append

Keep decision-read models flat and keys short-lived: a key per period, per order or per booking, not one key for the life of the company.

Choose something else when:

  • The rule is uniqueness. A constraint is cheaper and covers every writer.
  • A hint is enough. A plain read costs one lookup. Do not pay for a fold to display a message.
  • The state is too big or too hot. A key with a long history, or a model type read by every command, makes each command fold again.
  • The projection cannot be admitted. Reducers, self-reducing models, joins, children, FromEvery, by-property keys and open-ended event types are refused. Use a flat, event-source-keyed model-bound projection, an aggregate or a stream decision instead.
  • The state is not in the event log. External data, another database or another event sequence is not guarded.
  • You do not write .NET. Decision reads exist only in the .NET client.

Protection is a per-command choice: unmarked commands keep the behaviour they had before protected decisions existed. When protected decisions were introduced, the stated rule was: protection is opt-in, and unmarked commands keep their current behavior. The reasons are mechanical:

  • A protected command fails closed. It refuses a validator with constructor dependencies, an unguardable read shape, and a direct commit of its unit of work, instead of running unguarded. Making that the default would turn existing commands that work today into errors.
  • Existing code reads plain models in validators and handlers in many places. Moving them is a rewrite of the rule, not a flag.
  • A decision read has a real cost, which a team should accept where the rule needs it.

The cost of opt-in is that a plain read next to a protected decision stays unguarded without any failure. The analyzer ARCCHR0011 reports plain reads in event-producing commands, but at Info severity, which is easy to miss in a build log. Raise it for projects that rely on protected decisions:

[*.cs]
dotnet_diagnostic.ARCCHR0011.severity = warning
dotnet_diagnostic.ARCCHR0012.severity = warning

An application-wide setting and automatic conversion of plain reads whose projection is admitted do not exist today. Screenplay takes the opposite default: under an opted-in language version it protects every declared read. That decision is recorded in Screenplay’s decision log; it does not change how Arc commands behave today.

SituationWhat happensFailure
A plain read next to a protected decisionThat part of the rule is advisorySilent
An immediate IEventLog.Append in a protected commandIt is written before the owner commits and stays if the commit conflictsSilent
A command with no event source id propertyEach call gets a random id; nothing can conflictSilent
The first append into a scopeNot checked by default; opt in with CheckFirstAppendIntoAScopeSilent
A manual ConcurrencyScope without a sequence numberThe kernel skips itSilent, visible in ConcurrencyCheckPerformed
Revise or Redact between read and appendNot detectedSilent
A projection definition changed in another processNot detected until this client re-registersSilent
Other event sequences, external state, non-canonical GUID forms from other SDKsNot guardedSilent
A writer that decides on the rule without a guardIts own decision is not protected, although a guarded writer still detects its eventSilent
A unique-constraint index update that fails after the append is durableLogged; a later duplicate can pass until a reindex repairs itSilent
A conflictThe command is rejected, no retryLoud
A refused read shape, a model without Id, a validator with constructor dependencies on a protected commandThe command fails every timeLoud

Each row, with the diagnostics that catch it and how to fix it, is on pitfalls.