Pitfalls that break a rule
Use this page to check a command you wrote, or to find out why a rule did not hold. A silent failure leaves the command successful and the rule broken. A loud failure rejects the command or throws. Most dangerous pitfalls are silent, and several have no analyzer.
Summary
Section titled “Summary”| Pitfall | Failure | Reported by | Fix |
|---|---|---|---|
| A uniqueness check instead of a constraint | Silent | Nothing | Constraint |
| A plain read next to a protected decision | Silent | ARCCHR0011 (Info) | DecisionRead<T> |
| An immediate append in a protected command | Silent: partial commit | ARCCHR0007 (Warning), ARCCHR0012 (Info) | Return the events |
| An aggregate commit whose result is ignored | Silent | Nothing | Return Commit() |
| No event source id, or two candidates | Silent | ARCCHR0002 (Warning) for two candidates | EventSourceId or ICanProvideEventSourceId |
| A decision-read model without an Id | Loud: error response when the command runs | Nothing at build time | An Id property |
| A model that folds a different key than the facts are written to | Silent | Nothing | Write the facts to the read key |
concurrency: true as a guard | Silent | ARCCHR0016 (Warning) only with exact scopes; ARCCHR0011 (Info) for a plain read | A real guard |
| A decision read on the wrong kind of command | Loud when unmarked, silent when [Unprotected] | Nothing | [ProtectedDecision] |
| Results you must check | Silent | Nothing | Check IsSuccess, commit before you respond |
| A validator with constructor dependencies | Loud: HTTP 500 | Nothing at build time; ARCCHR0011 (Info) points the way | Parameterless validator, or move the rule |
| The first append into a scope | Silent | Nothing | Opt in |
| Where the guard stops | Silent | Nothing | Outside the guarantee |
| Tests without decision mode | Silent | Nothing | UseDecisionReads() |
Two failures are loud by design. A conflict rejects the command with the reason concurrencyViolation and is never retried; the caller reads again and resubmits. A read shape that decision reads refuse fails the command each time it runs; DecisionReadAdmissions in your specs finds it before production for DecisionRead<T> parameters of protected commands; a model read through IDecisionReads.Get<T> needs its own Admit<T>() check or a scenario that runs it (see protected decision scenarios).
All examples on this page share one namespace.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections.ModelBound;
[EventType]public record CopyAdded();
[EventType]public record BookBorrowed(Guid MemberId);
[EventType]public record BookReturned();
[EventType]public record LoanTaken(string Isbn);
[EventType]public record LoanReturned(string Isbn);
[EventType]public record MemberRegistered(string Email);
public record BookStock( EventSourceId Id, [Increment<CopyAdded>, Decrement<BookBorrowed>, Increment<BookReturned>] int Available);
public record MemberLoans( EventSourceId Id, [Increment<LoanTaken>, Decrement<LoanReturned>] int Active);A uniqueness check instead of a constraint
Section titled “A uniqueness check instead of a constraint”A check followed by an append leaves a gap. Two requests both see “not taken” and both append.
using Cratis.Arc.Commands.ModelBound;using Cratis.Arc.Validation;using Cratis.Monads;
public interface IMemberDirectory{ Task<bool> EmailExists(string email);}
[Command]public record RegisterCheckedMember(string Email){ public async Task<Result<MemberRegistered, ValidationResult>> Handle(IMemberDirectory directory) => await directory.EmailExists(Email) ? ValidationResult.Error("A member with this email address is already registered.") : new MemberRegistered(Email);}Nothing reports it, and it works in every test that runs one request at a time. Use a constraint. The same holds for a validator that asks the question.
A plain read next to a protected decision
Section titled “A plain read next to a protected decision”A protected command guards the decision reads it takes. A plain read in the same command is advisory, and the difference is one type name:
using Cratis.Arc.Chronicle.ReadModels;using Cratis.Arc.Commands.ModelBound;using Cratis.Arc.Validation;using Cratis.Chronicle.Events;using Cratis.Monads;
[Command][ProtectedDecision]public record BorrowBookPlainRead(EventSourceId BookId, Guid MemberId){ public Result<BookBorrowed, ValidationResult> Handle(BookStock stock) => stock.Available <= 0 ? ValidationResult.Error("No copies of this book are available.") : new BookBorrowed(MemberId);}The command is marked, it compiles and it runs, and it guards nothing: BookStock is a plain read, so two members can both see Available: 1. Arc refuses nothing. It reports the parameter as ARCCHR0011 at Info severity. ARC0006 warns on a non-nullable parameter only for types marked [ReadModel] or targeted by an IProjectionFor<T> in the same compilation, so it does not report this model-bound BookStock.
Take every state the rule depends on as a DecisionRead<T>, or as IDecisionReads.Get<T> for another key. Raise ARCCHR0011 to a warning in projects that rely on protected decisions:
[*.cs]dotnet_diagnostic.ARCCHR0011.severity = warningAn immediate append in a protected command
Section titled “An immediate append in a protected command”IEventLog.Append writes at once, before the owner commits and ignoring the decision read. If the commit then conflicts, the immediate event stays.
using Cratis.Arc.Chronicle.ReadModels;using Cratis.Arc.Commands.ModelBound;using Cratis.Arc.Validation;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.ReadModels;using Cratis.Monads;
[EventType]public record BorrowingRecorded(EventSourceId BookId);
[Command][ProtectedDecision]public record BorrowBookImmediate(EventSourceId BookId, Guid MemberId){ public async Task<Result<BookBorrowed, ValidationResult>> Handle( DecisionRead<BookStock> stock, IEventLog eventLog) { if (stock.Instance is not { Available: > 0 }) { return ValidationResult.Error("No copies of this book are available."); }
await eventLog.Append(MemberId.ToString("D"), new BorrowingRecorded(BookId)); return new BookBorrowed(MemberId); }}Arc reports ARCCHR0007 as a warning on the IEventLog parameter and ARCCHR0012 as Info on the call. A successful immediate append counts as committed, so the command ends with a partially committed state. Return the events, or stage them with eventLog.Transactional.Append(...), as in the scenarios.
An aggregate commit whose result is ignored
Section titled “An aggregate commit whose result is ignored”An aggregate’s Failed(...) accumulates errors, and only Commit() turns them into a result.
using Cratis.Arc.Chronicle.Aggregates;using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;
[EventType]public record CopyLent(Guid MemberId);
public class Copy : AggregateRoot{ bool _lent;
public async Task Lend(Guid memberId) { if (_lent) { Failed("The copy is already lent."); return; }
await Apply(new CopyLent(memberId)); }
public void OnCopyLent(CopyLent @event) => _lent = true;}
[Command]public record LendCopyIgnoringTheResult(EventSourceId CopyId, Guid MemberId){ public async Task Handle(Copy copy) { await copy.Lend(MemberId); await copy.Commit(); }}
[Command]public record LendCopyReturningTheResult(EventSourceId CopyId, Guid MemberId){ public async Task<AggregateRootCommitResult> Handle(Copy copy) { await copy.Lend(MemberId); return await copy.Commit(); }}The first command ends successfully even when the copy was already lent: the failure is in the result that nothing reads. The second returns it, and Arc turns it into the command’s outcome. The same silent success follows if an aggregate applies an event and then calls Failed(...): the applied event is already staged, and automatic completion does not read the failure list.
Commit() is also the last staging step. It commits the whole command unit, so do not stage anything afterwards. Do not combine an aggregate with [ProtectedDecision] reads: Commit() throws once a decision read is enrolled. For aggregate events and returned events on one event source, the ordered-batch path throws and the older AddEvent path silently replaces the earlier scope; choose one way to write.
No event source id, or two candidates
Section titled “No event source id, or two candidates”A command with no event source id property gets a new random id on every call, so nothing can ever conflict. A raw Guid is not an event source id:
using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;
[EventType]public record PaymentRecorded(decimal Amount);
[Command]public record RecordPaymentToRandomStream(Guid AccountId, decimal Amount){ public PaymentRecorded Handle() => new(Amount);}Declare the id as EventSourceId (or an EventSourceId<T>-derived type) so the event goes to the account’s stream. When a command has two such properties, Arc silently uses the first one declared, which also becomes the decision-read key, and ARCCHR0002 is the only signal (a warning). [Key] on one of them does not silence it:
using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;
[EventType]public record LoanRecorded(EventSourceId Member, EventSourceId Book);
[Command]public record RecordLoanWithTwoKeys(EventSourceId Member, EventSourceId Book){ public LoanRecorded Handle() => new(Member, Book);}
[Command]public record RecordLoanWithAKey(EventSourceId Member, EventSourceId Book) : ICanProvideEventSourceId{ public EventSourceId GetEventSourceId() => Member;
public LoanRecorded Handle() => new(Member, Book);}The second command is the fix: ICanProvideEventSourceId names the stream explicitly ([Key] marks a candidate but does not silence the warning). The key also decides which instance a DecisionRead<T> reads, so the choice is part of the rule. See resolving the event source id.
A decision-read model without an Id
Section titled “A decision-read model without an Id”A decision read matches the command’s key to the model’s Id property, which must be a string-like or Guid property.
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.Monads;
public record BookStockWithoutId( string Isbn, [Increment<CopyAdded>, Decrement<BookBorrowed>] int Available);
[Command][ProtectedDecision]public record BorrowBookWithoutId(EventSourceId BookId, Guid MemberId){ public Result<BookBorrowed, ValidationResult> Handle(DecisionRead<BookStockWithoutId> stock) => stock.Instance is not { Available: > 0 } ? ValidationResult.Error("No copies of this book are available.") : new BookBorrowed(MemberId);}This compiles. When the command runs, Chronicle refuses the model with the reason KeyConversion, and the command fails with DecisionReadCouldNotBeAcquired every time. The same happens for a reducer, a join, a child collection, FromEvery, a key from an event property, or a projection with no event types. Rename the key to Id, or use a stream decision, an aggregate or a constraint. Add DecisionReadAdmissions.ShouldAdmitDecisionReadsIn(...) to your specs; it checks the DecisionRead<T> parameters of protected commands, and a model read with IDecisionReads.Get<T>(key) needs its own Admit<T>() check or a scenario that runs it.
The model folds a different key than the one the facts are written to
Section titled “The model folds a different key than the one the facts are written to”A decision read folds the events of its key. Events written to another event source are invisible to it.
using Cratis.Arc.Chronicle.ReadModels;using Cratis.Arc.Commands.ModelBound;using Cratis.Arc.Validation;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.ReadModels;using Cratis.Monads;
[Command][ProtectedDecision]public record TakeLoanOnItsOwnStream(EventSourceId Member, string Isbn){ public Result<EventForEventSourceId, ValidationResult> Handle(DecisionRead<MemberLoans> loans) => loans.Instance is { Active: >= 3 } ? ValidationResult.Error("The member has reached the loan limit.") : new EventForEventSourceId(EventSourceId.New(), new LoanTaken(Isbn));}MemberLoans counts LoanTaken on the member’s stream, but each loan is written to a new stream. The count is always zero, the limit never triggers, and the guard watches a stream no one writes to. Write the facts to the key you read (return new LoanTaken(Isbn) goes to Member), or model the rule on one key that carries all of them. A rule over keys that can join the set later, such as loans that each live on a book stream, is not guarded: a new key is in nobody’s read set.
concurrency: true is not the guard
Section titled “concurrency: true is not the guard”concurrency: true on [EventSourceType], [EventStreamType] and [EventStreamId] narrows the check that Arc builds for returned events. That check reads the tail after Handle(), so it cannot bind your decision to what you read.
using Cratis.Arc.Commands.ModelBound;using Cratis.Arc.Validation;using Cratis.Chronicle.Events;using Cratis.Chronicle.ReadModels;using Cratis.Monads;
[Command][EventSourceType("Book", concurrency: true)]public record BorrowBookNarrowed(EventSourceId BookId, Guid MemberId){ public async Task<Result<BookBorrowed, ValidationResult>> Handle(IReadModels readModels) { var stock = await readModels.GetInstanceById<BookStock>(BookId); return stock is { Available: > 0 } ? new BookBorrowed(MemberId) : ValidationResult.Error("No copies are available."); }}The plain IReadModels read is reported by ARCCHR0011 at Info severity; nothing reports the belief that the flag protects the decision. Use concurrency: true to choose which dimensions bound the default check, not to protect a decision. ARCCHR0016 reports the flag only when the same command also returns exact scopes, where it has no effect.
A decision read on the wrong kind of command
Section titled “A decision read on the wrong kind of command”| Command | DecisionRead<T> behaviour | Failure |
|---|---|---|
[ProtectedDecision] | Guarded read | None |
[Unprotected] | An unguarded read, shared within the invocation | Silent |
| Unmarked | Refused with ProtectedDecisionReadRequiresProtectedCommand | Loud |
Mark the command [ProtectedDecision] before you take DecisionRead<T>. A command with both attributes runs as protected and then throws CommandCannotBeBothProtectedAndUnprotected.
Results you must check when you use Chronicle directly
Section titled “Results you must check when you use Chronicle directly”Outside a command, nothing checks results for you.
using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Microsoft.AspNetCore.Http;using ScopeBuilder = Cratis.Chronicle.EventSequences.Concurrency.ConcurrencyScopeBuilder;
[EventType]public record Withdrawn(decimal Amount);
public static class DirectAppends{ public static async Task<IResult> IgnoresTheResult(IEventLog eventLog, EventSourceId accountId) { await eventLog.Append(accountId, new Withdrawn(10m)); return Results.Ok(); }
public static async Task<bool> BuildsAnIncompleteScope(IEventLog eventLog, EventSourceId accountId) { var scope = new ScopeBuilder().WithEventSourceId(accountId).Build(); var result = await eventLog.Append(accountId, new Withdrawn(10m), concurrencyScope: scope); return result.ConcurrencyCheckPerformed; }}Appendreturns anAppendResult: a constraint violation or a concurrency violation is a value, not an exception.IgnoresTheResultreports success whatever happened. CheckIsSuccess.- A scope with no sequence number and no
ExpectingNoMatchingEvent()is skipped by the kernel, andConcurrencyCheckPerformedisfalse. Set a sequence number, or useExpectingNoMatchingEvent(). - With
IDecisionReads.Get<T>and a unit of work, commit withIUnitOfWorkCompletionFeature.CommitAsync()before you respond. The request middleware commits only after the endpoint has returned, and it reports no concurrency violation to the caller. - A plain
AppendafterGet<T>in the same request is written immediately and bypasses the read’s guard. UseTransactional.Append. - In an Arc project, qualify Chronicle’s
ConcurrencyScopeBuilder:Cratis.Arc.Chronicle.Commandshas a static class with the same name.
The Level 3 scenarios show the correct shapes.
The first append into a scope
Section titled “The first append into a scope”The first event on a new event source, or the first with a new event source type, stream type or stream id, has no tail to compare against. By default Chronicle skips the check, so two concurrent creations can both succeed. Opt in application-wide, or ask for it per append with ExpectingNoMatchingEvent():
using Cratis.Chronicle;
public static class FirstAppendCheck{ public static void Configure(ChronicleOptions options) => options.ConcurrencyOptions.CheckFirstAppendIntoAScope = true;}The option rejects appends that succeed today, and its default is planned to change in the next major version. Read concurrency in Chronicle before you turn it on. A new aggregate and a protected decision read already check their first append.
Where the guard stops
Section titled “Where the guard stops”The guard covers the events in the read set. It does not cover:
ReviseandRedactbetween the read and the append;- a projection definition changed by another process, until this client registers again;
- other event sequences and external or non-Chronicle state;
- a unique-constraint index update that fails after the append is durable: Chronicle logs it, a later duplicate can pass, and a reindex repairs it;
- constraints across namespaces: tenants in different namespaces can claim the same value.
Do not design a rule that relies on any of these.
The guard also does not protect a writer that decides without one. 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.
Tests without decision mode
Section titled “Tests without decision mode”A scenario without UseDecisionReads() does not exercise protected decision reads. AppendConcurrently still queues a competing writer before the command commits, so you can test an explicit concurrency scope or stream decision. Pinned read models cannot supply a decision guard. For protected decision reads, seed real events and opt into protected decision scenarios.
Where to go next
Section titled “Where to go next”- Choosing how a command’s rule holds: the decision matrix.
- Scenarios: the correct shape for each rule.
- Code analysis: every
ARCCHRrule. - Chronicle’s Dynamic Consistency Boundary FAQ: the same guards seen from Chronicle’s side. See also how event source definitions bound a command’s append.