Skip to content

One stream

Use a per-stream read model when a rule depends on the folded state of one stream on the command’s own route. October’s withdrawals should not make November’s command conflict. Declare the model’s stream, take it as a plain parameter, and put the rule in a validator. Under On, Arc guards that stream.

Rule lives in…Use
Folded state of one stream on the command’s own routePer-stream model
The entity’s whole history, such as a balanceWhole-source model. Combining it with a per-stream model gives a whole-source guard
“Has fact X happened in this short stream?”, without a modelStream decision
Rich behaviour across the whole entityAggregate. Do not also read a model of the same source
Another month or another sourceIDecisionReads stream overload in Provide()
Uniqueness across sourcesConstraint
RuleAt most 5000 may be withdrawn per account per month
MechanismA per-stream model and a CommandValidator<T> under On
AlternativesA stream decision for short raw-event checks; a whole-source model when every month must count
NotA key-only read of a per-stream model: Chronicle refuses it
using Cratis.Arc.Chronicle.Commands;
using Cratis.Arc.Commands;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Arc.Queries.ModelBound;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSources;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.ReadModels;
using FluentValidation;
[EventSource("BankAccount")]
[EventStream("Transactions")]
public class BankAccountEventSource : IEventSource;
public record AccountId(string Value) : EventSourceId(Value);
[EventType]
public record MoneyWithdrawn(decimal Amount);
[ReadModel, EventStream<BankAccountEventSource>("Transactions")]
public record MonthlyWithdrawals(
AccountId Id,
[AddFrom<MoneyWithdrawn>(nameof(MoneyWithdrawn.Amount))] decimal Total)
{
public static async Task<MonthlyWithdrawals?> ForMonth(AccountId account, EventStreamId month, IReadModels models)
{
var instance = await models.GetInstanceById<MonthlyWithdrawals>(account, month);
return instance is null ? null : await models.Release(instance);
}
}
[Command, ReadProtection(ReadProtectionMode.On)]
[EventSource<BankAccountEventSource>("Transactions")]
[EventStreamId("{Month}")]
public record Withdraw(AccountId Account, string Month, decimal Amount)
{
public MoneyWithdrawn Handle() => new(Amount);
}
public class WithdrawValidator : CommandValidator<Withdraw>
{
public WithdrawValidator(MonthlyWithdrawals? month) =>
RuleFor(command => command.Amount)
.Must(amount => (month?.Total ?? 0m) + amount <= 5000m)
.WithMessage("Monthly limit reached");
}

The model keeps the account as its Id; the month selects the fold, not a new model key. Chronicle makes the model passive, so a stored instance never mixes months. Arc resolves {Month} before constructing the validator. A missing or blank Month returns 400 naming that property, without reading. The validator rejects an exceeded limit; Handle() only returns the event.

guard account: types=[MoneyWithdrawn], sourceType=BankAccount, streamType=Transactions, streamId=2026-10, after <position before the read>

If another withdrawal arrives in October after the validator’s read, commit returns 400, concurrencyViolation, ReadChanged, and writes nothing. November activity does not conflict. Validator and Handle() parameters of the same model share one enrolled fold and released instance. A cached read is still checked when it is enrolled again.

Under Off and Measure, the same parameter folds only that month but supplies no guard. Measure reports the stream dimensions and coarsened when another read drops them. Existing models without the stream declaration keep their existing behavior.

ForMonth is a model-bound query on the [ReadModel] record above. It calls the plain stream overload, which takes the source and stream types from the declaration. It returns October for that account, not a stored whole-account total. It guards nothing. Do not use a key-only lookup, collection read or Watch for this model; Chronicle refuses them.

  • The command needs a matching source type and stream type, and an explicit, non-default stream id. [EventSource<BankAccountEventSource>("Transactions")] keeps writers aligned. Matching string routes work too.
  • Another definition, stream, All, or a missing stream id is refused in every mode with ReadModelStreamNotOnCommandRoute. ARCCHR0037 catches source-visible mismatches.
  • Do not put [LookUpBy] on a per-stream parameter. The source always comes from the command’s route. Read another account or month explicitly through IDecisionReads in Provide(); see stream reads.
  • A whole-source read on the same account chooses a whole-source guard. Another stream read can widen it too. Declared Concurrency that omits stream dimensions can also widen it; ARCCHR0038 reports source-visible cases. Use no declared policy for the model’s exact event types, or stream-level EventSourceId | EventStreamType | EventStreamId for an any-event stream boundary.
  • The declaration’s source type filters both fold and guard. Events written without that source type are not included. Every writer must use the same definition route. Revisions and redactions remain outside the guard.

Use a stream decision for the raw question “has this report been approved in this short stream?”. Use a per-stream model when you need an approval count or folded status; the period approval model shows that alternative.

using Cratis.Arc.Chronicle.Commands;
using Cratis.Arc.Chronicle.Streams;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSources;
[EventSource("Report")]
[EventStream("Approval", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)]
public class ReportEventSource : IEventSource;
public record ReportId(string Value) : EventSourceId(Value);
[EventType]
public record ReportApproved;
[Command]
[EventSource<ReportEventSource>("Approval")]
[EventStreamId("{Owner}:{Month}")]
public record ApproveReport(ReportId Report, string Owner, string Month)
{
public Task<StreamDecision> Provide(IStreamReads reads) => reads.ForCommand();
public EventsWithConcurrencyScopes Handle(StreamDecision decision) =>
decision.Has<ReportApproved>()
? decision.Append([])
: decision.Append(new ReportApproved());
}

The decision reads and guards Approval/owner-1:2026-10 for this report. Any competing event in that stream after the read refuses the append, including the first append. Another month does not conflict. The whole stream crosses the wire, so keep it short. The empty append is an identical retry: approval was already recorded, so nothing is written or checked again.

Choose a stream decision or a per-stream validator, not both. The validator reads before Provide(); a later stream-decision boundary may not cover it and is refused with ExplicitScopeDoesNotCoverReads. ARCCHR0036 warns about that combination. An aggregate plus a per-stream read of the same source is refused too.