---
name: cratis-arc-command-validation
description: Add a rejection rule to an existing Arc command — choosing between ConceptValidator, CommandValidator, a short-circuiting Provide, and a read-model-injected Handle returning a Result of either the event or a ValidationResult. Use when a command must refuse work under some condition. Do not use to define a new command, and do not use for append-time Chronicle constraints.
license: MIT
---

# Arc command validation

Every rejection a command can produce has exactly one right home. Putting a rule
in the wrong one is not a style problem: a value invariant written per-command
stops travelling with the value, and a state-dependent rule written in a
validator loses the race it exists to win.

**Never throw for a normal business rejection.** An exception out of `Provide()`
or `Handle()` is caught by the pipeline and merged as `HasExceptions`, which the
HTTP layer maps to **500** — not a validation result. Recoverable, user-facing
rejections are validation. (The one exception: an exception type implementing
`IValidationFailure` is converted to a validation failure and returns 400. Arc
uses that for its own dependency-resolution failures; do not build application
rejection on it.)

## Verified product sources

| Package | Version | Purpose |
| --- | --- | --- |
| `Cratis.Arc.Core` | `22.75.0` | `CommandValidator<T>`, `ConceptValidator<T>`, `ValidationResult`, `CommandResult`, the command pipeline, `ARC0004`–`ARC0013` |
| `Cratis.Fundamentals` | `7.23.0` | `Cratis.Monads.Result<TResult, TError>` |
| `Cratis.Chronicle` | `18.3.0` | Read-model resolution for command-side injection |
| `FluentValidation` | `12.1.1` | `RuleFor(...)`, `.WithMessage(...)` |

> Arc validation and decision boundaries rechecked at v22.75.0. Arc depends on
> Chronicle 19.39.1 / Fundamentals 7.23.0; separate product claims retain their
> version-qualified verification.

Fundamentals rechecked at v7.23.0 in
[`Result<TResult, TError>`](https://github.com/Cratis/Fundamentals/blob/v7.23.0/Source/DotNET/Fundamentals/Monads/Result%7BTResult,TError%7D.cs):
success is first, error second, with implicit conversions from both types.

Reverify against the owning product repository before claiming support for
another version.

## Route near misses

- Defining a new command, its `Handle()` return shape, or event-source id
  resolution: use `cratis-arc-command`.
- Uniqueness, or any rule the **event store** must enforce at append time: use
  `cratis-chronicle-event-constraints`. A read-model pre-check for uniqueness is
  not race-safe and is the classic wrong answer here.
- Authorization: that is a filter and an attribute at the boundary, not a
  validator. Use `cratis-arc-authentication-authorization-and-identity`.
- Writing the specification for the rejection: use
  `cratis-application-slice-specifications`.
- An accepted `.play` model under the model root covers the behavior, or the
  repository is opted in (the root holds a committed `.play` file (`git ls-tree -r --name-only HEAD` lists a `.play` file there, narrowed to `-- <root>` when a root is configured), or the project set
  `mcpServers.screenplay.root` in `.cratis/ai.json`; an empty directory, install
  output, an uncommitted `.play` draft or a `.play` file outside the root does not count; master definition:
  `cratis-screenplay-modeling-lifecycle`): change the model first with
  `cratis-screenplay-event-modeling`. If the Screenplay skills are not installed,
  say so and do not author `.play` from memory.
  Edit code here only for infrastructure, clients, adapters, Screenplay code
  attachments, or gap-fill scope (`cratis-screenplay-render-and-gap-fill`);
  never edit Stage-managed output.

## Step 1 — Choose the mechanism by what the decision *is*

Which mechanism makes a rule hold under concurrent writers (constraint, protected
decision, stream decision, aggregate, scope, reactor repair) is routed in
[choosing how a rule holds](../cratis-chronicle-decision-reads/references/choosing-how-a-rule-holds.md).
A validator is never that mechanism.

| The rule is… | Put it in |
| --- | --- |
| A reusable value invariant — length, format, range | `ConceptValidator<T>` on the concept type |
| Command input, cross-field, or a pre-handler check | `CommandValidator<TCommand>` with `RuleFor(...)` |
| A dependency on data the handler must fetch first | `Provide()` — fetch, and short-circuit if unusable |
| State-dependent and must hold **under concurrency** | Guard the read with `IStreamReads`/`StreamDecision`, or `[ProtectedDecision]` + `DecisionRead<T>`; return events through Arc |
| Uniqueness | A Chronicle constraint, never a command-side read |
| A genuine defect or missing infrastructure | `throw` a domain exception |

The pipeline runs **authorization → validation → `Provide()` → `Handle()`**.
Authorization filters are explicitly ordered ahead of validation filters, so a
caller who may not act never learns whether their input was valid.

## Step 2 — Value invariants: `ConceptValidator<T>`

```csharp
public class EmailAddressValidator : ConceptValidator<EmailAddress>
{
    public EmailAddressValidator() => RuleFor(_ => _.Value).EmailAddress();
}
```

A `ConceptValidator<T>` is discovered automatically and applies **globally**.
Arc walks the whole object graph of every model it validates and runs a
validator resolved by each node's runtime type — so the rule fires on every
command *and every query* carrying that concept, wherever it appears. That is
the point: declare the invariant once and it cannot be forgotten on the next
command.

When one command legitimately needs the concept unchecked, opt that property out
in its own validator with `.IgnoreConceptRules()` rather than weakening the
concept rule for everyone.

`ARC0013` warns when a validator rule dereferences a possibly-null concept
member.

## Step 3 — Command input rules: `CommandValidator<T>`

```csharp
public class TransferFundsValidator : CommandValidator<TransferFunds>
{
    public TransferFundsValidator() =>
        RuleFor(c => c.Amount).GreaterThan(0).WithMessage("Amount must be positive.");
}
```

- `CommandValidator<T>` is a FluentValidation `AbstractValidator<T>` underneath,
  so `RuleFor`, the standard rule set, and `.WithMessage(...)` are
  FluentValidation's. Arc adds one overload that unwraps a `ConceptAs<T>` to its
  primitive so `RuleFor(c => c.SomeConcept)` reads naturally.
- The validator is discovered by type — no registration.
- Constructor dependencies (read models, services) are injected. A read model
  injected here resolves the same way as one injected into `Handle()`; see
  Step 5 for what "the same way" actually means. A validator that reads state is
  **advisory only**: it shows the state at validation time and guards nothing, so
  use it for a friendly early message, never as the rule. A `[ProtectedDecision]`
  command refuses a discoverable validator unless it has exactly one public
  parameterless constructor (otherwise HTTP 500), so keep state rules out of it.
- **Omit the validator entirely when there are no rules.** An empty subclass is
  noise the reader has to check.
- Single-property intrinsic rules belong on `ConceptValidator<T>` instead, so
  they travel with the value everywhere.
- A validator that itself throws does not become a 500 — Arc catches it and
  emits a validation error with reason `ValidatorFailed`, returning 400.

## Step 4 — Data the handler needs: `Provide()`

`Provide()` runs after authorization and validation and before `Handle()`. Its
parameters resolve from dependency injection; the command instance is `this`.

```csharp
public async Task<Result<Customer, ValidationResult>> Provide(IReadModels readModels)
{
    var customer = await readModels.GetInstanceById<Customer>((EventSourceId)CustomerId);
    return customer is null ? ValidationResult.Error("Customer must exist.") : customer;
}
```

- Returning a `ValidationResult`, an `IEnumerable<ValidationResult>`, an
  `AuthorizationResult`, or a `CommandResult` short-circuits: `Handle()` is
  never invoked.
- A `Result<TProvided, ValidationResult>` has its inner value unwrapped, so the
  success arm flows to `Handle()` and the error arm short-circuits.
- A returned tuple is flattened and its elements bound to `Handle()` parameters
  by type. **Every provided value must be consumed by a `Handle()` parameter** —
  an unused one is `ARC0005`.
- Keep the fetch in `Provide()` and the decision in `Handle()`. Do not write a
  pass-through `Provide()` that only forwards a read model `Handle()` could take
  directly, and do not duplicate one rejection in both the validator and
  `Provide()` — pick one owner.

## Step 5 — Guard a concurrency-sensitive decision

Plain read-model injection or `IReadModels.GetInstanceById` is **advisory**, even
inside `Handle()` returning `Result<,>`: it enrolls no guard against a competing
append. Choose the first boundary that fits at Arc v22.75.0:

1. One routed stream: `Provide(IStreamReads reads) => reads.ForCommand()` returns
   `Task<StreamDecision>`; `Handle(StreamDecision decision)` returns
   `decision.Append(...)` (`EventsWithConcurrencyScopes`), guarding the read tail.
2. Projected state: mark the command `[ProtectedDecision]` and take
   `DecisionRead<T>` (`.Exists`/`.Instance`) or command-aware `IDecisionReads`.
   Only Chronicle-admitted, direct event-source-keyed projections with known
   event types qualify; reducers (including self-reducing models), joins, children and open-ended event
   types are refused. The model needs an `Id` property.
   Only parameterless validators constructed/certified by Arc run. Constructor
   dependencies are refused; static/service-locator decision state is still your
   responsibility. Command-level `[Unprotected]` opts out, not into safety.
3. Stream-level automatic scopes: `concurrency: true` on routing attributes
   (`EventStreamId`, `EventStreamType`, `EventSourceType`). It is **not** a guard
   for the decision: like the default check it reads the tail after `Handle`.
   Exact returned scopes take precedence; do not combine automatic capture with
   exact scopes. A guard detects every matching event after its boundary, whoever
   wrote it, but it protects only guarded decisions, so every writer that decides
   on the rule needs one, or the rule must be a constraint.

`ARCCHR0011/0012` flag unprotected reads and immediate append bypasses. Return
events, never append them directly. Read
[protected and stream decisions](../cratis-arc-command/references/decisions-and-routing.md)
when choosing the guard. This handler excerpt assumes `CartSummary` is an
admitted projection; domain types are the slice's existing types.

```csharp
using Cratis.Monads;
using Cratis.Arc.Chronicle.ReadModels;

[Command]
[ProtectedDecision]
public record AddItemToCart(CartId CartId, ItemId ItemId)
{
    /// <summary>Adds the item; rejects when the cart already holds the maximum.</summary>
    /// <param name="cart">The current cart summary, resolved by the command's event-source id.</param>
    /// <returns>The event on success, or a validation error.</returns>
    public Result<ItemAddedToCart, ValidationResult> Handle(DecisionRead<CartSummary> cart) =>
        !cart.Exists
            ? ValidationResult.Error("Cart must exist.")
            : cart.Instance.ItemCount >= 3
                ? ValidationResult.Error("A cart can hold at most 3 items.")
                : new ItemAddedToCart(ItemId);
}
```

- `Result<TSuccess, TError>` is `Cratis.Monads.Result<,>` from Cratis
  Fundamentals — **success first, error second** — and needs
  `using Cratis.Monads;`. Its implicit conversions are what let both `return`
  statements above compile without wrapping.
- **Return the error; never throw it.**
- One parameter per logical read model; several reads mean several parameters.

⚠️ **Two things the resolution does *not* do.** It does not key off the
`[ReadModel]` attribute — a read model becomes injectable because a Chronicle
projection or reducer, an EF Core `DbSet<T>`, or a MongoDB collection backs it.
And there is **no slice or feature restriction**: registration is global by
type, so "the read model must be in the same slice" is a convention with nothing
enforcing it.

The resolution is **by the command's resolved event-source id**, not by the
read-model type and not by "the property that looks like its key". If the
instance you need is keyed by anything else — a referenced other entity, a
command with more than one candidate identity, or an id generated inside
`Handle()` — direct injection hands you the wrong instance or none. Read by
explicit key in `Provide()` instead:

```csharp
var other = await readModels.GetInstanceById<Other>((EventSourceId)otherId);
```

A missing instance resolves to `null`, never to a default object. A nullable
parameter receives that `null`; a non-nullable one is **rejected as a validation
failure (400)**, not crashed. `ARC0006` warns on a non-nullable command-scoped
read-model parameter for exactly this reason. Handle a nullable parameter by rejecting (a validator,
`Provide()`, or `Result<TEvent, ValidationResult>`), never by returning a
nullable event from `Handle()`. `cratis-arc-command` carries the
full resolution and nullability reference, including the passive-projection case
where absence is *not* `null`.

## Step 6 — Specify the rejection

Every rule gets a specification of its failure case, using
`CommandScenario<TCommand>`. For protected reads, call `UseDecisionReads()`
before seeding actual events; pinned read models cannot supply a guard. Queue a
competing append on the relevant route and assert the conflict. Assert **both** that the command did not succeed
and that it failed *as a validation*:

```csharp
[Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
[Fact] void should_have_validation_errors() => _result.ShouldHaveValidationErrors();
```

`ShouldNotBeSuccessful()` alone cannot tell a rejection from an unhandled
exception, which is precisely the mistake this skill exists to prevent. Do not
assert on a message string by default — it is presentation text. Assert exact
wording only when it is the specified behavior, and name that requirement in
the fact. See
`cratis-application-slice-specifications` for the scenario mechanics.

## What breaks

- **The rejection returns HTTP 500.** Something threw instead of returning a
  validation result. Check `Provide()` and `Handle()` for a `throw` on a
  recoverable path.
- **The rule reads the wrong instance.** The injected read model is keyed by the
  command's event-source id and the command carries a different identity. Read
  by explicit key in `Provide()`.
- **The rule never fires.** The read-model type has no backing Chronicle
  projection, reducer, `DbSet<T>`, or MongoDB collection, so nothing registered
  it for command-side resolution.
- **Two requests both win.** A plain read was mistaken for a protected decision;
  moving it to `Handle()` or returning `Result<,>` does not guard it. Enroll a
  stream/projection decision read, or use an append-time uniqueness constraint.
- **`ARC0005` on build.** `Provide()` returns a value no `Handle()` parameter
  consumes.

## How it is proven

`dotnet build` in Debug and in Release, zero warnings and zero errors — the
analyzers above are how `ARC0005`, `ARC0006` and `ARC0013` surface. Then
`dotnet test` with a specification per rule, each asserting both
`ShouldNotBeSuccessful()` and `ShouldHaveValidationErrors()`. A rule with no
red-first specification has not been shown to reject anything.
