Skip to content

Adopting read protection

Use this page when you have commands that work today and want read protection without breaking them. New code can start on Getting started with On.

StepModeWhat you doBehaviour change
1OffUpdate the packages. Nothing elseNone
2MeasureSet <CratisReadProtection>Measure</CratisReadProtection>. Run the application and your testsNone for callers. Arc records what On would do
3MeasureRead the report. Fix each refused read, or annotate it [Unprotected("why")]The fixes are ordinary code changes
4On, per projectSwitch one project at a time. Run its testsReads are guarded. Refused reads fail the command

You can mix modes. A command-level [ReadProtection(ReadProtectionMode.Off)] keeps one command off while its project is On, and [ReadProtection(ReadProtectionMode.On)] turns one command on in an Off project. A command-level [Unprotected] means Off for that command.

The next major version of Arc makes On the default for projects that do not set <CratisReadProtection>. A project that sets the property keeps its mode through the upgrade. See The default changes in the next major version.

Under Measure, reads, lookups, scopes, results and status codes stay as they are. Arc looks at each command and records what On would do. It never changes the outcome, and a failure inside the measurement never fails the command.

WhatWhenCost
Classifies each read-model parameter and each other readOn the request path, locallyNone beyond reflection
Reads the event log’s tail once at the start of the commandOn the request pathOne awaited call, with a timeout
Scans for events that appeared during the command and match what it readIn the background, on a sample of commandsBackground reads on the shared connection

Measure is best-effort. A command whose tail read timed out, or whose scan was dropped, is reported as measurement_unavailable and counts as no evidence either way. The report is a set of hypotheses, not an enforced guard.

Measure settings are runtime configuration, in the same ReadProtection section as the run-time cap:

KeyDefaultMeaning
MeasureSampleRate0.1Fraction of commands whose reads are scanned in the background. Between 0 and 1
MeasureTailTimeout250Milliseconds to wait for the tail read at the start of a command. Between 1 and 5000
MeasureScanTimeout5000Milliseconds a background scan may take. Between 1 and 5000
MeasureScanLimit500The most events one background scan looks at. Between 1 and 500

Measure reports through three channels.

ChannelWhat you get
MetricCounter cratis.arc.read_protection.events on the meter Cratis.Arc.ReadProtection, with the tags command, category, model and reason
TraceA span event named read-protection.plan on the current activity, with the same tags
LogA Warning once per command and category for refused_read, immediate_append and scope_refusal, and a periodic Information summary

Start with the counter, grouped by category and command.

CategoryWhat it meansWhat to do
protected_readThe read would be guardedNothing
refused_readThe read would be refused. reason names the cause or the APISee the next table
advisory_readThe read is declared [Unprotected]. reason is your textCheck the reason still holds
unknown_dependencyA service or other dependency Arc cannot seeNothing, but it is outside the guard. It is never counted as protected
redirectThe On lookup rule would select a different key than the one the legacy path usedAdd [LookUpBy(nameof(Property))] to say which key you mean
immediate_appendThe command appended through an IEventLog and On would refuse itReturn the event from Handle(), or use IEventLog.Transactional
displacementAn explicit scope or stream decision sits beside a read of the same sourceThe two must be reconciled under On
scope_refusalOn would refuse the command: its explicit scope is narrower than the read, captured after it, or incompleteWiden the scope, or remove it
coarsenedReads of the same source would drop stream dimensions; reason=stream_dims_droppedA whole-source read beside a stream read, or reads of different streams, chooses a whole-source guard
nested_concurrent, nested_early_completionA nested command ran in parallel with a sibling, or tried to commit the outer unitRun nested commands one after another and commit in the outer command
self_writeOne of the command’s own events matches what it readInformational. Own events are excluded from matching_events_since_start
matching_events_since_startAn event written by someone else matched the read between its start and the scan. On might have returned 400A real race. This is the data that shows where protection matters
conflict_todayAn append of this command already failedInformational
measurement_unavailableA measurement could not be taken. reason says whyRaise the timeouts, or ignore it
mode_loweredThe run-time cap lowered the declared modeSee Cap the mode at run time

Measure does not currently emit routing_replaced or routing_kept_separately: its observed scopes do not retain the distinction between explicit guards, default routing and declared routing, including scopes derived inside Chronicle at commit. It also cannot report declared-routing coarsening from those observations. It does not emit nested_overlap: append notifications arrive after commit, not when an outer command stages an event before a nested read. Absence of these categories is not evidence that the cases are safe under On.

The reason of a refused_read is the Chronicle refusal. Refusals has the full table. The common ones:

reason or APIFix
ReducerRead a projection of the same events, or use an unfiltered reducer without constructor dependencies
ReducerDependencies, ReducerFiltersRemove the reducer’s constructor dependencies or its event filter
Join, HierarchyRead the parts as separate models
FromEventProperty, NotEventSourceKeyedAppend a fact to the entity the model is keyed by, or use a constraint
GenerationDeliveryNotSupportedUse Compatibility delivery for the projection
KeyConversionGive the model a string or Guid Id
StreamKeyRequiresStreamScopeRead the stream-keyed model with IDecisionReads.Get and an explicit stream in Provide()
IReadModels, IEventStore, IEventSequenceTake the model as a parameter, or mark the method [Unprotected("why")]
IMongoCollectionInject the read model instead of its collection
DetachedReadWhileReadsAreProtectedUse IDecisionReads.Get in Provide(). A detached read is refused

Build-time diagnostics mark many of the same sites: ARCCHR0024, ARCCHR0026, ARCCHR0028. Raise them to errors in the projects you move.

On is quiet for commands over source-keyed projections. It is noisier for:

SituationProtected optionHonest opt-outRoll out
Reducer-backed modelAn unfiltered reducer without constructor dependencies is admitted. Otherwise re-express it as a projection[Unprotected("reference data; staleness tolerated")] on the model typeKeep the project in Measure until it is done
Joined or enriched modelSeparate source-keyed models with [LookUpBy][Unprotected("copied as of read; may be stale")] on the parameterKeep the command Off
List or searchA constraint[Unprotected] on Provide()
Service that reads ChronicleA parameter, or IDecisionReads in Provide()[Unprotected] on Handle()

Switch one project at a time.

  1. Set <CratisReadProtection>On</CratisReadProtection> for the project.
  2. Build. Fix ARCCHR0024, ARCCHR0028 and ARCCHR0033 warnings. ARCCHR0011 no longer applies under On.
  3. Run the project’s tests. Command scenarios automatically select the in-process log from the command’s read-protection mode and dependencies; see Test the race.
  4. Exercise every command once. Refusals happen when a command runs, not at startup.

Under On, discoverable validators with constructor dependencies must be registered by convention, which makes them transient. A validator registered by hand as scoped, singleton, a factory or an instance is refused. See Validators.

Under On, a missing required protected read-model parameter now returns the ordinary validation reason rule (ValidationResultReason.Rule) instead of dependencyUnavailable, with the message {ReadModel} '{key}' does not exist.. HTTP status remains 400. Update clients or specs that distinguish these reasons. Invalid protected keys produce the same rejection before a read is acquired, including for nullable parameters; nullable parameters still receive null for a valid missing key. Off, Measure and advisory reads keep their previous behavior. See Missing models and invalid keys.

Operations can lower the mode without a build. The cap only lowers. It never raises a mode that was not compiled in.

{
"ReadProtection": {
"MaximumMode": "Measure"
}
}
MaximumModeEffect
On (or missing, or invalid)Declared modes apply
MeasureCommands declared On run as Measure
OffCommands declared On or Measure run as Off

At startup, Arc logs a Warning for each command whose declared mode is lowered. Measure reports a mode_lowered observation with the declared and effective modes. Use the cap to back out of an incident while you fix the code, and remove it afterwards: while it lowers the mode, reads run with the lower mode’s behavior instead of On’s guards.

  • Reads: what each read becomes under On.
  • Refusals: every error with its fix.
  • Pitfalls: what still fails silently.