Skip to content

Questions and a decision guide · Decisions that hold, Part 11

Decisions that hold · Part 11 of 11

This series started from a bug most applications have and few tests find: a rule reads state, decides, and writes, and two requests can both pass the check before either writes. Parts 2 to 6 made one tool dependable for that, Arc’s read protection, which turns a read model in a command into a guard on the command’s append. Parts 7 to 10 covered the rules that need something else, the same guard without Arc, how to test it, and how a guard is built. This last part is the page to come back to: a decision guide by the shape of the rule, short answers to the questions the series raised, and where each one is explained.

The answers assume read protection is On, as in new Cratis applications (Part 2), unless they say otherwise. Read protection, command scenarios and the Arc analyzers are part of Arc for .NET.

Start from the rule, not from the tool. Ask these questions in order, and the first yes picks the mechanism:

Seven numbered questions on the left, each with an arrow to a mechanism and the part that covers it. One: a value or event once? Constraint, Part 7. Two: no stored state needed? Stateless validator, Part 7. Three: fine to repair later? Reactor repair, Part 7. Four: client sent a revision? Explicit scope, Part 7. Five: one entity, many rules? Aggregate root, Part 7. Six: one routed stream? Stream model or decision, Part 4. Seven: otherwise, a guarded read under On, Parts 2 and 3.

Ask in order. A guarded read is what is left when none of the other shapes fits.

The table has more rows than the questions, because some rules have a shape the questions don’t separate. “Never rely on” is a choice that can fail silently: the command succeeds and the rule is broken. “Too much” is a choice that works and costs more than the rule needs.

Rule shape Use Never rely on Too much Part
A unique value (email, user name) A constraint: [Unique] on the event property A validator that asks “does it exist?” Read protection only for uniqueness 7
Created once (one library card per member) [Unique] on the event type An “exists” check in a validator A read over the whole history 7
State on one key (stock, a count, a status) A read-model parameter under On, the rule in a CommandValidator<T> The same validator under Off or Measure A constraint: it can’t count 2
A rule over two keys (loans and stock) Two read-model parameters with [LookUpBy], one event per key in one append Two aggregates, each guarding only its own source One model that folds both keys: it is refused 3
One stream of an entity (a month, a period) A per-stream read model, or a stream decision for “has it happened?” An injected read of the stream under Off A whole-source read when one period matters 4
An editing conflict An explicit scope with the revision the client saw concurrency: true alone A read model that folds what a revision tells you 7
Rich behaviour on one entity An aggregate root that returns Commit() Ignoring the result of Commit() A read model for each invariant 7
A rare, repairable violation The default append and a reactor that repairs The repair running before the harm Any guard 7
A hint on screen (“no copies left”) A query, or [Unprotected("why")] on the read The hint as the rule A guarded read 5, 7
External data (a price, a tax rate) Fetch it in Provide(); the guard covers your own state Assuming the external value is guarded Guarding state you don’t decide from 7
A hot key with a long history A key per period, order or booking, or a constraint A guarded read on a key that never stops growing A flat model over years of events 2
Code without Arc (an endpoint, a worker) A decision read passed to the append, or enrolled in the request’s unit of work A plain Append beside it, or an unchecked result Read protection on a unit of work for one guarded append 8

Arc’s Choosing how a command’s rule holds has the same matrix, the flowchart and what each mechanism guards. Chronicle’s Choosing how a rule holds makes the same choice from the client’s side, without Arc.

Does a validator that reads a read model make my rule hold?

Section titled “Does a validator that reads a read model make my rule hold?”

Under Off, the default, no. The validator gets a snapshot, a materialized model can lag behind the log as well, and nothing ties the commit to what the validator saw. Under On, yes, within the guard: the read is folded fresh from the event log, and the commit is refused if an event of the model’s types arrives for that key after the position Chronicle noted before the fold. It can’t make a value unique across keys, and it doesn’t see what’s listed under what a guard doesn’t cover. See Part 2 and Arc’s Getting started.

How do I turn it on, and why isn’t it on by default?

Section titled “How do I turn it on, and why isn’t it on by default?”

One property in the project that contains the commands, or in a Directory.Build.props that covers several:

<PropertyGroup>
<CratisReadProtection>On</CratisReadProtection>
</PropertyGroup>

It’s compiled into the assembly, and any value other than Off, Measure or On fails the build. [ReadProtection(ReadProtectionMode.On)] turns on one command. New applications from the Cratis templates have On already. Arc’s own default is Off, because a protected command fails closed: commands that read through IReadModels or append as a side effect would start failing. Making On the default is planned for a future major version (Cratis/Arc#3255). See Part 2 and Part 6.

Do I still need [ProtectedDecision] and DecisionRead<T>?

Section titled “Do I still need [ProtectedDecision] and DecisionRead<T>?”

Under On you don’t need [ProtectedDecision] or DecisionRead<T>; they keep working. A plain read-model parameter is guarded, and so are a validator’s constructor parameters. Outside On, [ProtectedDecision] is how one command opts in on its own, and Arc runs such a command’s validator only if it has no constructor parameters. See Part 2 and Arc’s FAQ.

Does a guard lock anything, and what does it cost?

Section titled “Does a guard lock anything, and what does it cost?”

Nothing is locked and nothing waits. Two commands on the same key both run to the commit, and the commit is where one of them may be refused. The cost is in the read: a fresh fold of the key’s history of the model’s event types on every guarded read, two tail reads per attempt, and up to three attempts if events arrive while it folds. Nothing caches the result between commands, and the kernel serves the folds through one projection per model type, shared by every key and tenant, so heavy use of one model type queues across keys. No benchmark of Arc’s read protection is published; Chronicle’s costs and limits page has one decision-read measurement with its environment. Keep guarded models flat and keys short-lived. See Part 2.

A guard covers one key’s history, for the event types the model folds, after the position noted before the read. Outside it:

  • Revise and Redact between the read and the commit. They don’t move the tail, so they aren’t detected, and Chronicle refuses them inside a protected invocation.
  • Other event sequences, services, HTTP calls and other databases.
  • A projection definition changed by another process.
  • Reads marked [Unprotected("why")], and every read under Off or Measure.
  • New keys a rule counts over. Put such a count on one key that receives every fact.
  • A writer that decides without a guard. Your guard detects its event if it lands in your window, but its own decision is unguarded.

See Part 2, Part 3 and Arc’s How it works.

Under Off and Measure, yes. A model-bound command honours the caller’s X-Allowed-Severity, and a caller that sends 3 (Error) removes the Error results of your validators and Provide(), unless the command declares [BlockOnValidationSeverity]. From Arc 22.108.0, a command declared On, in the project or on the command, has an Error floor: Error and Unknown results block whatever the caller sends, and the caller can still let warnings through. The floor follows the declared mode, so a runtime cap doesn’t remove it, and a command’s own [BlockOnValidationSeverity] takes precedence. No threshold turns a ReadChanged from the commit into a success. See Part 5 and Arc’s severity filtering.

No. Measure returns what Off returns, with the same status codes, and reports what On would guard, refuse or reject through the counter cratis.arc.read_protection.events on the meter Cratis.Arc.ReadProtection, a span event and log warnings. An application in Measure is exactly as unguarded as one in Off. The measurement is best effort and the race scan is sampled, so a quiet report is not evidence that nothing would change. Use it to find what to fix, then switch a project to On. To back out after a deployment, ReadProtection:MaximumMode lowers the mode at run time and never raises it. See Part 6.

No. Arc and Chronicle do not re-run the command. A lost race returns a 400 with the reason concurrencyViolation, the detail ReadChanged and a state of { readModel, key }, and nothing from the command is written. The fold makes up to three attempts while it reads, before your code decides; after that, a conflict ends the command. The caller reads again and decides again. Resubmitting the same decision in a loop would undo the point of the guard. The message doesn’t name the event that conflicted. See Part 2 and Part 5.

What should a UI do with ReadChanged and ReadIncomplete?

Section titled “What should a UI do with ReadChanged and ReadIncomplete?”

Both are 400s with the reason concurrencyViolation, and both mean nothing was written. ReadChanged means something the command read changed before the commit: reload what the user decided from and let them decide again. ReadIncomplete means the fold didn’t reach that key’s latest events in three attempts: submitting again is safe. Branch on reason; reasonDetail is a plain string with no TypeScript constant, the message is developer text, and neither result names a field. See Part 5 and Arc’s command result page.

Why does my command return a 500 under On?

Section titled “Why does my command return a 500 under On?”

Usually the command reads or appends something Arc can’t put under a guard. The common causes are a read model with joins, children or a key taken from an event property, a filtered reducer or one with dependencies, a read through IReadModels or the event log inside the command, an immediate IEventLog.Append, an aggregate and a read of the same source, or a validator registered by hand as scoped, as a singleton, or through a factory or an instance (a plain transient registration is fine). Part 5 lists the rest. Nothing is written, and submitting again fails the same way, so the code has to change. Outside Development, the message is replaced by a generic one unless ArcOptions.ExposeExceptionDetails is set; find the full text in the server log by the result’s correlationId. ARCCHR0024, ARCCHR0028 and ARCCHR0033 report most of these at build time. See Part 5 and Arc’s refusals.

Chronicle can’t replay a reducer exactly when it filters its events or depends on a service. A reducer is admitted when it has no event filter and no constructor dependencies other than ILogger<T>. Remove the filter or the dependencies, read a projection of the same events, or mark the read [Unprotected("why")] if it’s only context. See Part 5 and Chronicle’s decision reads.

What if my read model is keyed by an event property?

Section titled “What if my read model is keyed by an event property?”

It can’t be guarded. Its events live on other event sources, one per book for example, and a guard watches one source. ARCCHR0024 warns at build time, and under On the command is refused with a 500. Append a fact to the entity the model is keyed by, so the command writes one event to each entity in one append, or use a constraint. [Unprotected("why")] only turns the read back into a snapshot. See Part 3.

With a constraint on the event: [Unique] on a property for a value, on the event type for “once per entity”. The kernel checks it inside every append, from every writer and every client. A validator can’t do it, and neither can read protection: two members registering the same email are two different keys, and no read of one key sees the other. A constraint is checked per event sequence and per namespace, and it can’t count. See Part 7 and Chronicle’s constraints.

How do I enforce a rule over two entities?

Section titled “How do I enforce a rule over two entities?”

Take one read model per entity in the validator, each with [LookUpBy(nameof(...))] naming the command property that holds its key, and return one EventForEventSourceId per entity from Handle(). Each read guards its own key, both guards are checked with the one atomic append, and if either was violated, neither event is written. Use a separate event type for each entity: a model folds every event of its types, wherever it was appended. See Part 3.

What’s the difference between a per-stream read model and a stream decision?

Section titled “What’s the difference between a per-stream read model and a stream decision?”

A per-stream read model folds state, a total or a status, from one stream of an entity, such as one account’s Transactions for 2026-10. Declare [EventStream<TSource>("Transactions")] on the model, route the command with [EventSource<TSource>("Transactions")] and [EventStreamId("{Month}")], and the guard covers the model’s event types in that stream. A stream decision reads the raw events of the command’s stream and answers “has this happened?”. Any event in that stream after the read conflicts, and the whole stream crosses the wire, so it suits short streams. Use one of them in a command, not both. See Part 4 and Arc’s stream decisions.

Does an event source definition, or concurrency: true, make my rule hold?

Section titled “Does an event source definition, or concurrency: true, make my rule hold?”

No. Both narrow the default append check, and that check reads the event source’s tail when the events are appended, after the validator and Handle() have decided. It can’t tie the commit to what the decision read. For that you need a constraint, a guarded read, a stream decision, an aggregate or an explicit scope. See Part 7 and Arc’s FAQ.

Why did my command fail when nothing it decided on changed?

Section titled “Why did my command fail when nothing it decided on changed?”

The guard is wider than the read you were thinking of. Reads of one event source merge into one guard over the event types of all of them, so a retitle refuses a borrow whose validator also read a title model of the same book. A whole-source read beside a per-stream read widens the guard to every month, reading two months of one stream drops the stream id, and a Concurrency declared on the definition with the event source id but not both stream dimensions widens it the same way. A wider guard still includes everything the narrower one watched; it adds conflicts and doesn’t drop any. Mark reads you only use as context [Unprotected("why")], and take only the per-stream model when the rule is about one period. ARCCHR0038 reports the combinations it can see. See Part 4, Part 5 and Part 10.

Questions about Chronicle, other languages and DCB

Section titled “Questions about Chronicle, other languages and DCB”

Can I use this from Kotlin, Java, TypeScript or Elixir?

Section titled “Can I use this from Kotlin, Java, TypeScript or Elixir?”

Arc’s read protection, command scenarios and analyzers are part of Arc for .NET. Chronicle’s decision reads, the mechanism underneath, are in the .NET and Elixir clients. Constraints and concurrency scopes are in all five Chronicle clients, and the kernel checks a constraint on every append, whichever client sent it. See Part 7 and Part 8.

Yes. In the .NET client, GetDecisionReads().GetDetached<T>(key) notes the position and folds the key, and AppendMany(…, guardedBy: [read]) appends with that read as the condition. Check IsSuccess, and GetDecisionConflicts to tell a lost race from a constraint violation: a conflict is a result, not an exception. Inside an ASP.NET Core request, IDecisionReads.Get<T> enrolls the read in the request’s unit of work; stage events with Transactional.Append and commit with IUnitOfWorkCompletionFeature.CommitAsync() before you respond, because the middleware otherwise commits after the response and reports nothing. In Elixir, read with DecisionReads.get inside Chronicle.decide. See Part 8 and Chronicle’s decision reads.

Chronicle supports DCB-style decision models and append conditions within its event-source model. It is not a DCB event store. Each event belongs to one event source, and the event source ID, event source type, event stream type and event stream ID play the part of tags, with one value each. Custom tags take no part in consistency, and a fact about two entities is two events in one atomic append. See Part 8, Einar’s DCB, but not DCB and DCB in Chronicle.

Questions about testing and seeing the guard

Section titled “Questions about testing and seeing the guard”

Put a competing write between the command’s read and its commit. In Arc, new CommandScenario<T>(), seed events with Given.ForEventSource(id).Events(...), queue the competitor with AppendConcurrently(id, ...), and assert ShouldHaveValidationErrorBecauseOf(ValidationResultReason.ConcurrencyViolation) and an empty AppendedEvents. The scenario runs the command in the mode it declares, in its project or on the command, so under Off or Measure the test fails, which is what it’s for. Seed events rather than pinned read models: a guarded read never sees a pinned instance. See Part 9 and Arc’s Test the race.

In code that calls Chronicle directly with read protection enforced on the unit of work, call IUnitOfWork.GetAppendConditions() after the commit. It returns one condition per guarded event source, with its event types, stream values, position and the origins behind it. Assert on the origins and the number of conditions rather than on their text. See Part 10 and Chronicle’s How guards are derived.

  1. The bug you never see. Check-then-act decisions, why two requests can both pass the check, and what the database defaults do about it.
  2. The read model said yes. A command that checks a read model and then appends, and the project setting that makes the check hold at the append.
  3. One rule, two entities, one append. Two reads, one fact for each entity, and one atomic append.
  4. A limit for one account and one month. Guarding a decision on one stream, so last month’s activity doesn’t conflict with today’s.
  5. When Arc says no. The two kinds of refusal, what each means, and what the caller and the UI should do.
  6. Turning read protection on in an application that already runs. Measuring first, fixing what it finds, and switching on per project.
  7. Not every rule needs a guarded read. Constraints, aggregates, explicit scopes, stream decisions, repair and hints, and the choices that fail silently.
  8. The same guard without Arc: decision reads and append conditions. The Chronicle calls underneath, and how they map to the Dynamic Consistency Boundary.
  9. Testing a decision against a race. Putting a competing write between a command’s read and its commit.
  10. How a guard is built, merged and widened. Predicting exactly which events make a command fail.
  11. Questions and a decision guide. This page.

For Arc commands, start at Choosing how a command’s rule holds. From there, Getting started turns On for a first command, Adopting read protection is the path from Off through Measure, Refusals lists every error with its fix, How it works derives and merges guards, Pitfalls covers what fails silently, and the FAQ has more short answers.

For Chronicle without Arc, start at Dynamic Consistency Boundary, which has a reading order. Choosing how a rule holds picks the mechanism, DCB in Chronicle says what maps and what doesn’t, How guards are derived and Costs and limits go into the detail, and its questions and answers cover the client’s side. The API pages are Decision reads and Read protection.

Read protection as described here needs Arc 22.108.0 or later and Chronicle 19.68.0 or later; per-stream read models need Chronicle 19.71.0 or later; decision reads need a Chronicle server of 19.0.0 or later.