@cratis/pi 7.60.1 → 7.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package/corpus/agents/spec-writer.md +2 -2
  2. package/package/corpus/harnesses/copilot/instructions/application-profile.instructions.md +2 -2
  3. package/package/corpus/harnesses/copilot/instructions/glossary.instructions.md +2 -2
  4. package/package/corpus/harnesses/copilot/instructions/profiles.instructions.md +2 -0
  5. package/package/corpus/harnesses/copilot/instructions/specs.scenarios.csharp.instructions.md +7 -4
  6. package/package/corpus/harnesses/copilot/instructions/vertical-slices.instructions.md +22 -13
  7. package/package/corpus/product-sync.json +3 -2
  8. package/package/corpus/prompts/add-business-rule.prompt.md +1 -1
  9. package/package/corpus/rules/application-profile.md +2 -2
  10. package/package/corpus/rules/glossary.md +2 -2
  11. package/package/corpus/rules/issue-assignment.md +31 -0
  12. package/package/corpus/rules/profiles.md +2 -0
  13. package/package/corpus/rules/specs.scenarios.csharp.md +7 -4
  14. package/package/corpus/rules/vertical-slices.md +22 -13
  15. package/package/corpus/skills/cratis-application-slice-specifications/SKILL.md +4 -3
  16. package/package/corpus/skills/cratis-application-slice-specifications/references/query-and-decision-scenarios.md +12 -3
  17. package/package/corpus/skills/cratis-arc-authentication-authorization-and-identity/SKILL.md +12 -7
  18. package/package/corpus/skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md +11 -11
  19. package/package/corpus/skills/cratis-arc-command/SKILL.md +3 -2
  20. package/package/corpus/skills/cratis-arc-command/references/decisions-and-routing.md +95 -51
  21. package/package/corpus/skills/cratis-arc-command/references/fresh-identifiers.md +5 -2
  22. package/package/corpus/skills/cratis-arc-command/references/read-model-injection.md +9 -5
  23. package/package/corpus/skills/cratis-arc-command-validation/SKILL.md +99 -52
  24. package/package/corpus/skills/cratis-chronicle-decision-reads/SKILL.md +37 -15
  25. package/package/corpus/skills/cratis-chronicle-decision-reads/references/admission-and-ownership.md +3 -2
  26. package/package/corpus/skills/cratis-chronicle-decision-reads/references/choosing-how-a-rule-holds.md +277 -138
  27. package/package/corpus/skills/cratis-chronicle-event-constraints/SKILL.md +4 -4
  28. package/package/corpus/skills/cratis-chronicle-event-modeling/SKILL.md +6 -4
  29. package/package/corpus/skills/cratis-chronicle-read-model/SKILL.md +2 -1
  30. package/package/corpus/skills/cratis-chronicle-reducer/SKILL.md +6 -3
  31. package/package/corpus/skills/cratis-code-review/SKILL.md +6 -3
  32. package/package/corpus/skills/cratis-screenplay-streams-and-consistency/references/consistency-and-concurrency.md +5 -3
  33. package/package/corpus/skills/cratis-specifications-csharp/SKILL.md +3 -2
  34. package/package/corpus/skills/cratis-specifications-csharp/references/application-scenarios.md +1 -1
  35. package/package.json +1 -1
@@ -2,14 +2,18 @@
2
2
 
3
3
  ## Contents
4
4
 
5
- - Route first
5
+ - Defaults
6
+ - Route by rule shape
6
7
  - The matrix
7
- - A plain read guards nothing
8
+ - Reads under Off, Measure and On
8
9
  - Constraints
9
- - Decision reads and protected decisions
10
+ - What a read guard covers
11
+ - Refusals
10
12
  - Arc traps
11
13
  - Chronicle-only traps
12
- - Costs and why protection is opt-in
14
+ - Roll out with Measure
15
+ - Test the race
16
+ - Costs
13
17
  - Checklist for generated code
14
18
 
15
19
  <!-- Copyright (c) Cratis. All rights reserved. -->
@@ -20,73 +24,133 @@ question: **which mechanism makes this rule hold while other writers append at t
20
24
  same time**, and which look correct but do not. Read it before writing a command,
21
25
  validator, endpoint or handler that reads state and then appends.
22
26
 
23
- Checked against the Chronicle and Arc sources. Published guides, which
24
- this agrees with:
27
+ Published guides this agrees with:
25
28
  [Chronicle: choosing how a rule holds](https://www.cratis.io/chronicle/dynamic-consistency-boundary/choosing/),
26
29
  [decision reads](https://www.cratis.io/chronicle/read-models/decision-reads/),
27
30
  [Arc: choosing how a command's rule holds](https://www.cratis.io/arc/backend/csharp/chronicle/consistency/).
28
31
 
29
- ## Route first
30
-
31
- Take the first step that matches the rule.
32
-
33
- 1. **A value must be unique, or something may exist once** (email, ISBN, one
34
- registration per source): a constraint. Not a read, not a validator.
35
- 2. **The rule uses data outside Chronicle events**: record the external fact as an
36
- event first, then decide on it; otherwise accept the risk and repair. No decision
37
- read covers external state.
38
- 3. **A rare violation is acceptable** (overbooking you can compensate): append
39
- normally and repair with a reactor that returns a correcting event.
40
- 4. **Someone edited what someone else changed** (the user saw revision N): an explicit
41
- concurrency scope built from the revision the user supplied.
42
- 5. **A rule over one key's history** (cannot ship before payment, stock or count
43
- on one key): a decision read of a flat, event-source-keyed projection.
44
- 6. **A rule over one stream inside a source**: a stream-scoped decision read
45
- (Chronicle) or `IStreamReads`/`StreamDecision` (Arc).
46
- 7. **A rule across two keys** (course capacity and a student's course limit): one
47
- decision read per key, one event per event source, one atomic `AppendMany`; or
48
- put every fact the rule needs on one event source and use one read.
49
- 8. **Rich behaviour over one lifecycle** (many rules, one key): a richer flat
50
- projection read as one decision read, or in Arc an aggregate root.
51
- 9. **Only a UX hint** (disable a button, show seats left): a plain read.
52
- 10. **Only input shape** (range, format, required): a validator. No guard needed.
32
+ ## Defaults
33
+
34
+ - Read protection is `Off` unless the project sets it. New code starts with
35
+ `On`: `<CratisReadProtection>On</CratisReadProtection>` in the project (it
36
+ emits `[assembly: ReadProtection(ReadProtectionMode.On)]`; an invalid value
37
+ fails the build). Existing code moves `Off` to `Measure` to `On`; see
38
+ [Roll out with Measure](#roll-out-with-measure).
39
+ - Under `On`, a read model that is a parameter of `Handle()` or of a
40
+ `CommandValidator<T>` constructor is read fresh from the event log and guards
41
+ the command's append. The state rule goes in the validator with the read model
42
+ as a constructor parameter; `Handle()` returns the events.
43
+ - `[ProtectedDecision]` with `DecisionRead<T>` is the older per-command opt-in. It
44
+ still works and is not obsolete. Use it only in a project that is not on `On`.
45
+ It is not redundant with `On`; do not remove it because `On` is set without
46
+ checking what the command takes.
47
+ - Subject ids derive from `EventSourceId` (`record Isbn(string Value) :
48
+ EventSourceId(Value)`), so the command needs no `[Key]`.
49
+
50
+ ## Route by rule shape
51
+
52
+ Take the first step that matches. Names S1 to S14 label the shapes used in the
53
+ published scenario catalogue.
54
+
55
+ 1. **A value is unique, or something exists once** (S2: email, ISBN, one card per
56
+ member): a constraint. Not a read, not a validator.
57
+ 2. **The rule uses data outside Chronicle events** (S11): record the external fact
58
+ as an event first, then decide on it; otherwise accept the risk and repair.
59
+ 3. **A rare violation is acceptable, a hint, or a hot key** (S11): no guard.
60
+ Append and repair with a reactor that returns a correcting event, or mark a
61
+ deliberate advisory read `[Unprotected("why")]`.
62
+ 4. **Someone edited what someone else changed** (S10: the user saw revision N): an
63
+ explicit concurrency scope built from the revision the client sent.
64
+ 5. **One entity's state** (S1: last copy, balance, count): the read model as a
65
+ parameter under `On`, the rule in a validator. Not an aggregate for one counter.
66
+ 6. **Two entities** (S3 loan limit and last copy; S9 course capacity and student
67
+ limit): two read-model parameters, the second chosen by convention or
68
+ `[LookUpBy(nameof(Command.Property))]`, and `Handle()` returns
69
+ `IEnumerable<EventForEventSourceId>` with one event per entity in one atomic
70
+ append. Use distinct event types per entity.
71
+ 7. **A limit inside one stream of one source** (S4: 5000 per account per month): a
72
+ per-stream read model, `[ReadModel, EventStream<TSource>("Transactions")]`, with
73
+ the command routed `[EventSource<TSource>("Transactions")]` and
74
+ `[EventStreamId("{Month}")]`. For a short stream and a question about raw events
75
+ (S5: approve a period once), a stream decision with `IStreamReads`.
76
+ 8. **Process stream with steps and completion** (S6): a definition with a stream per
77
+ process, declared concurrency and a closed stream.
78
+ 9. **Gapless numbers** (S7): a numbering source read with `IDecisionReads` in
79
+ `Provide()`; `[Unique]` alone gives no duplicates but allows gaps.
80
+ 10. **Rich behaviour over one lifecycle** (S8: order lines, pay, ship, cancel): an
81
+ aggregate root, returning `Commit()` last. An aggregate and a protected read
82
+ of the same source in one command are refused.
83
+ 11. **Moving an existing app to `On`** (S12): `Measure` first.
84
+ 12. **A command failed under protection** (S13): the [refusals](#refusals).
85
+ 13. **Prove it** (S14): [test the race](#test-the-race).
53
86
 
54
87
  ## The matrix
55
88
 
56
89
  | Rule shape | Use | Never use | Too much |
57
90
  | --- | --- | --- | --- |
58
- | Unique value | `[Unique]` property or `IConstraint` | read model check then append | decision read or scope (they guard one key's history) |
59
- | Create once | `[Unique]` on the event type | "does it exist" read then append | decision read |
60
- | One key's history, stock, count | decision read; Arc `[ProtectedDecision]` + `DecisionRead<T>` | plain read model, validator read, `concurrency: true` | aggregate for one counter |
61
- | Two keys | two decision reads + one event per source in one `AppendMany` | two separate appends, a guard on one side only | none |
62
- | One stream | stream-scoped decision read; Arc `IStreamReads` | whole-source read when one stream matters | none |
63
- | Edit conflict | explicit scope from the user's revision | last writer wins by accident | constraint |
64
- | Rich behaviour | richer projection read once; Arc aggregate | rules spread over validators and plain reads | aggregate for one rule |
65
- | Rare violation acceptable | append, repair with a reactor | presenting a plain read as a guarantee | decision read |
66
- | UX hint | plain read | treating the hint as enforcement | decision read (several calls and a full fold) |
67
- | Hot key, long history | shorten histories (split by period or stream); constraint for uniqueness | ignoring cost: each decision read refolds the key | none |
68
-
69
- Not a guard: a plain read, a `CommandValidator` that reads, `Result<,>`, returning
70
- `ValidationResult.Error`, `concurrency: true`, the default optimistic append, a
71
- reactor, or an `ExpectedRevision` you never pass into a scope.
72
-
73
- ## A plain read guards nothing
74
-
75
- Whatever the backing, a plain read captures no position and enrolls no guard.
76
-
77
- | Backing | Freshness | Guard |
91
+ | S2 Unique value, create once | `[Unique]` property or event type, `IConstraint` | "exists?" read then append | read model or decision read (they guard one key's history) |
92
+ | S1 One entity's state | read-model parameter + validator under `On` | validator over a plain read under `Off` (a hint) | aggregate for one counter |
93
+ | S3, S9 Two entities | two parameters, `[LookUpBy]` for the second key, `EventForEventSourceId` per key | a guard on one side only, two separate appends, one read model keyed through an event property | none |
94
+ | S4 Limit inside one stream | per-stream read model on a routed command | whole-source model when another stream's events must not conflict | none |
95
+ | S5 Once per period in a short stream | stream decision (`IStreamReads`) or per-stream model | whole-source read | none |
96
+ | S8 Rich lifecycle | aggregate root | aggregate plus a read of the same source | aggregate for one rule |
97
+ | S10 Edit conflict | explicit scope from the client's revision | `concurrency: true` alone (reads the tail after `Handle`) | constraint |
98
+ | S11 Rare violation, hint, external data | repair reactor, `[Unprotected("why")]`, or record the external fact first | presenting a plain read as a guarantee | any guard |
99
+ | S12 Existing app | `Measure`, then `On` per project | switching a whole app to `On` unmeasured | none |
100
+ | Hot key, long history | shorten histories (split by period or stream); constraint for uniqueness | ignoring cost: each guarded read folds the key's history | none |
101
+
102
+ Not a guard: a plain read under `Off` or `Measure`, a validator over such a read,
103
+ `Result<,>`, `ValidationResult.Error`, `concurrency: true`, the default optimistic
104
+ append, a reactor, or an `ExpectedRevision` never passed into a scope.
105
+
106
+ ## Reads under Off, Measure and On
107
+
108
+ | Mode | Read-model parameter, validator constructor | Results and status codes |
78
109
  | --- | --- | --- |
79
- | Materialized projection or reducer (default) | may lag the log | none |
80
- | `[Passive]` projection | fresh at read time | none |
81
- | `[Passive]` reducer | fresh at read time | none |
82
- | Self-reducing model | as its `[Passive]` setting | none |
83
-
84
- Passive is fresher, not safer. Capturing the tail yourself before a materialized
85
- read does not fix it: the read may be older than the position you captured, and
86
- the events in between are neither seen nor guarded. Do not rebuild a decision read
87
- by hand. Every Arc read-model injection (`Handle`, `Provide`, validator
88
- constructor) and every `IReadModels.GetInstanceById` is a plain read, on a
89
- protected command too.
110
+ | `Off` (default) | plain read; a hint, guards nothing | as today |
111
+ | `Measure` | plain read; Arc reports what `On` would do | same as `Off` |
112
+ | `On` | read fresh from the log; guards the command's append | a lost race is HTTP 400 |
113
+
114
+ A command overrides the project with `[ReadProtection(ReadProtectionMode.On)]`
115
+ (`Cratis.Arc.Commands`); command-level `[Unprotected]` means `Off`. The runtime
116
+ cap `ReadProtection:MaximumMode` only lowers (a missing or invalid value is `On`,
117
+ no cap); a lowered command logs a startup Warning. With the cap set, the `On`
118
+ behaviour does not hold.
119
+
120
+ A plain read does not capture a position even when the model is `[Passive]`:
121
+ passive is fresher, not safer. Do not rebuild a guard by hand (capturing the tail
122
+ yourself before a read): the read may be older than the position you captured.
123
+
124
+ Under `On`:
125
+
126
+ - `IReadModels`, `IMongoCollection` and detached reads in the command are refused.
127
+ Take the model as a parameter, or mark the method `[Unprotected("why")]` (it
128
+ covers the body, not injected parameters).
129
+ - Immediate appends (an injected `IEventLog.Append`) are refused. Return the events.
130
+ - The key is, in order: `[LookUpBy(nameof(Cmd.Prop))]` on the parameter
131
+ (parameter-only attribute, `Cratis.Arc.Commands`); the single command property
132
+ whose type equals the model's `Id` type; `AmbiguousReadModelLookup` (HTTP 500) if
133
+ several match; the command's event source id.
134
+ - Admitted: event-source-keyed model-bound or declarative projections with a
135
+ string or Guid `Id`, and unfiltered reducers without constructor dependencies
136
+ (except `ILogger<T>`; self-reducing models are exempt). Refused with
137
+ `ReadCannotBeProtected` unless `[Unprotected("why")]`: joins and children, a
138
+ model keyed by an event property, Pinned generation delivery, filtered reducers,
139
+ reducers with dependencies, other event sequences.
140
+ - A validator's read models are protected when the validator is convention
141
+ registered (transient). Hand-registered scoped, singleton, factory or instance
142
+ validators are refused.
143
+ - Under `[ProtectedDecision]` without `On`, only a parameterless validator runs.
144
+
145
+ Caller severity: model-bound commands accept `X-Allowed-Severity` (`allowedSeverity`).
146
+ Under `Off` and `Measure` a caller sending `Error` removes Error results from validators,
147
+ command filters and `Provide()`, so `Handle()` runs, unless the command declares
148
+ `[BlockOnValidationSeverity(ValidationResultSeverity.Error)]`. Requires Arc 22.108.0:
149
+ a command declared `On` gets an `Error` floor (`Error` and `Unknown` always block; the
150
+ caller can relax only `Warning` and `Information`). Lowering
151
+ `ReadProtection:MaximumMode` at runtime does not remove it, and an explicit attribute
152
+ wins. Before that version a validator's state rule can be skipped by the caller even
153
+ under `On`.
90
154
 
91
155
  ## Constraints
92
156
 
@@ -94,91 +158,113 @@ A constraint binds every writer, whichever client or service appends, per namesp
94
158
  and per event sequence. It cannot count, sum or test a predicate; it expresses
95
159
  uniqueness (with release on a removal event), create-once and closed streams.
96
160
 
97
- - The claim is settled after the durable append (not yet
98
- implemented in every store). A failed index update is logged and a later duplicate
99
- may pass until a reindex. SQL and InMemory stores do not yet settle claims
100
- A constraint is the right mechanism, not an
101
- unconditional proof.
161
+ - The claim is settled after the durable append (not yet implemented in every
162
+ store). A failed index update is logged and a later duplicate may pass until a
163
+ reindex. SQL and InMemory stores do not yet settle claims. A constraint is the
164
+ right mechanism, not an unconditional proof.
102
165
  - Tenants in different namespaces can claim the same value.
103
- - `[RemoveConstraint]` releases attribute constraints (`[Unique]` on a property
104
- or on the event type); on a fluent `IConstraint` it compiles and does nothing,
105
- so use `.RemovedWith<T>()` there.
166
+ - `[RemoveConstraint]` releases attribute constraints (`[Unique]` on a property or
167
+ on the event type); on a fluent `IConstraint` it compiles and does nothing, so
168
+ use `.RemovedWith<T>()` there.
106
169
  - It is not re-run when the kernel retries a duplicate sequence number.
107
170
  - For a friendly message use `.WithMessage`; Arc maps a violation to HTTP 400.
108
171
 
109
- ## Decision reads and protected decisions
110
-
111
- A decision read folds an admitted projection in a fresh session after capturing
112
- the log boundary, and the append is rejected if an event of the model's types
113
- for that key arrived after the boundary, **whoever wrote it** (the kernel checks the
114
- stored tail filtered by key, types and route). See the skill's
115
- [admission and ownership](admission-and-ownership.md) for the refusal list.
116
-
117
- - **Admission.** A flat projection keyed by event source, with a finite event-type
118
- set and an `Id` property (string, or Guid in lowercase hyphenated form). Reducers,
119
- self-reducing models, joins, children, `FromEvery`, event-property routing and
120
- other sequences are refused. A model without `Id`, or keyed by a concept that does
121
- not convert, refuses with `KeyConversion`; in Arc that surfaces as HTTP 500.
122
- - **Absent models are guarded.** A concurrent creation conflicts. Do not fabricate a
123
- default instance for an absent model.
172
+ ## What a read guard covers
173
+
174
+ Under `On` (and for a Chronicle decision read), if an event of the model's types
175
+ for that key arrives after the read, nothing from the command is written, whoever
176
+ wrote the event. The kernel checks the stored tail filtered by key, types and route.
177
+
178
+ - **Only that key and those types.** A concurrent event of another type (a rename)
179
+ does not conflict.
180
+ - **Absent models are guarded.** A concurrent creation conflicts. Do not fabricate
181
+ a default instance for an absent model.
124
182
  - **Cross-key.** A read on key X guards X even when the command appends only to Y.
125
- Per-key reads cover only keys in the read set: when new keys can join the set
126
- ("a member holds at most three loans" with each loan on its own source), write the
127
- facts on a key that is read, usually the member's.
128
- - **Every deciding writer must be guarded.** The guard detects every matching
129
- event appended after its boundary, but it protects only the decision that carries
130
- it. If the guarded write lands first, a writer that decides on the same facts
131
- without a guard (a plain read, an `[Unprotected]` command, a direct append) can
132
- still append on stale state and break the rule. The rule holds only when every
133
- writer that decides on it is guarded, or when it is a constraint, which checks
134
- every append.
135
- - **Conflict.** No automatic retry. Reread and decide again, or resubmit. Revise/redact,
136
- other sequences, external state, projection definition changes and writes that
137
- bypass the kernel's serialized append path (duplicate grain activations, direct
138
- storage writes) are outside the guard.
139
- - **Empty protected commit.** A unit that enrolled a decision read and staged no
140
- events appends an empty batch with the guard scopes. The kernel
141
- validators (`AppendManyValidator`, `AppendManyForEventSourcesValidator`) accept an
142
- empty batch when a scope carries an actual boundary or expects no matching event, and the integration
143
- spec `for_ReadModels/when_reading_for_a_decision` shows `AppendMany([], guardedBy:)`
144
- succeeds when nothing changed and reports a concurrency violation when the model
145
- changed. A kernel that rejects an empty batch ("At least one event is required.")
146
- surfaces as `DecisionReadValidateOnlyNotSupported`, which fails closed. Read from
147
- source, not run end to end: treat validate-only as supported on a current kernel
148
- and assert it with an integration specification before relying on it.
149
- - **Arc opt-in.** Protection is per command: `[ProtectedDecision]` marks a command
150
- that takes `DecisionRead<T>` or command-aware `IDecisionReads`; unmarked commands
151
- keep legacy behaviour, `[Unprotected]` opts out. There is no app-wide option, because
152
- making it default would refuse validators with dependencies, refused shapes and
153
- commands appending through an injected `IEventLog`.
154
- The key is the command's event source id; for other keys use
155
- `IDecisionReads.Get<T>((ReadModelKey)id)`.
183
+ Per-key reads cover only keys in the read set; when new keys can join the set
184
+ ("a member holds at most three loans" with each loan on its own source), write
185
+ the facts on a key that is read, usually the member's.
186
+ - **Every deciding writer must be guarded.** The guard protects only the decision
187
+ that carries it. A writer that decides on the same facts through a plain read, an
188
+ `[Unprotected]` read or a direct append can still append on stale state. The rule
189
+ holds only when every writer that decides on it is guarded, or when it is a
190
+ constraint.
191
+ - **Outside the guard:** revisions and redactions (append conditions do not detect
192
+ them), services and external stores, other event sequences, `[Unprotected]`
193
+ reads, `Off` mode, projection definition changes, and writes that bypass the
194
+ kernel's serialized append path (duplicate grain activations, direct storage
195
+ writes).
196
+ - **Conflict.** Arc and Chronicle do not re-run the command; the fold of a read
197
+ may retry up to three times. The client reads fresh state and decides again.
198
+ - **Aggregates.** Under `On`, an aggregate and a protected read of the same source
199
+ are refused (`ReadAndAggregateOnSameSource`); a read of a different source is
200
+ fine. Arc guards an aggregate's commit with a scope from its rehydration position.
201
+ - **Empty protected commit.** A unit that enrolled a read and staged no events
202
+ appends an empty batch with the guard scopes. Current kernels validate it and
203
+ write nothing; a kernel that rejects an empty batch surfaces
204
+ `DecisionReadValidateOnlyNotSupported`, which fails closed. Assert it in an
205
+ integration specification before relying on it.
206
+ - **Chronicle model.** Chronicle supports decision models and append conditions
207
+ within its event-source model: event source id, event source type, event stream
208
+ type and stream id play the role of tags, one value each per event. It is not a
209
+ DCB event store: an event belongs to one event source, and a fact about two
210
+ entities is two events in one atomic append. Custom tags take no part in
211
+ consistency.
212
+
213
+ ## Refusals
214
+
215
+ | Seen by the caller | Cause | Do |
216
+ | --- | --- | --- |
217
+ | HTTP 400, `ValidationResult` reason `concurrencyViolation`, `reasonDetail` `ReadChanged`, `state` `{ readModel, key }` | an event of the read model's types for that key arrived after the read; nothing was written | resubmit; the client reads fresh state and decides again |
218
+ | HTTP 400, `ReadIncomplete` | the fold did not finish after three attempts | resubmit |
219
+ | HTTP 500 | the command cannot be guarded: a code or configuration error | change the code; retrying does not help |
220
+
221
+ The `ReadChanged` message reads "BookStock 'isbn-1' changed after BorrowBook read
222
+ it. Nothing from this command was written; submit it again." It does not name the
223
+ conflicting event. Outside Development, a 500 message is replaced unless
224
+ `ArcOptions.ExposeExceptionDetails` is set. Frontend (TypeScript):
225
+ `CommandResult.isSuccess/isValid/validationResults`, `ValidationResult.reason`
226
+ (`'concurrencyViolation'`, `ValidationResultReason` from `@cratis/arc/validation`),
227
+ `reasonDetail` (a string with no TS constant), `state`.
228
+
229
+ Common 500 refusals: `ReadCannotBeProtected` (shape, above),
230
+ `AmbiguousReadModelLookup` (add `[LookUpBy]`), `ImmediateAppendWhileReadsAreProtected`
231
+ (return the event), `ReadRefusedWhileReadsAreProtected` and
232
+ `DetachedReadWhileReadsAreProtected` (parameter, `IDecisionReads.Get` in
233
+ `Provide()`, or `[Unprotected("why")]`), `ReadAfterProtectedCommit` (read first,
234
+ return `Commit()` last), `ReadOfSourceWithStagedEvents`, `ExplicitScopeDoesNotCoverReads`,
235
+ `DeclaredRoutingNotCoveredByExplicitScope`, `ConflictingReadProtection`,
236
+ `ReadModelStreamNotOnCommandRoute`.
156
237
 
157
238
  ## Arc traps
158
239
 
159
240
  | Trap | What happens | Fix |
160
241
  | --- | --- | --- |
161
- | Read model injected into `Handle`, `Provide` or a validator, then events returned | plain read; two requests both win | decision read, constraint or stream decision |
162
- | `CommandValidator` that reads state | advisory; also refused on a protected command | validator for shape rules only |
163
- | Protected command with a discoverable validator that has constructor dependencies or several public constructors | HTTP 500, nothing runs | parameterless validator (exactly one public parameterless constructor); move state rules into the decision |
164
- | `concurrency: true` on `EventStreamId`, `EventStreamType` or `EventSourceType` | reads the narrowed tail after `Handle`; changes granularity, not the decision. A command that also reads `IReadModels` still triggers `ARCCHR0011` | decision read, explicit scope or stream decision |
242
+ | Read model parameter or validator under `Off` or `Measure`, then events returned | plain read; two requests both win | set `On`, or use a constraint or stream decision |
243
+ | Validator over a plain read under `Off` | a hint with a friendly message; guards nothing | `On` |
244
+ | `concurrency: true` on `EventStreamId`, `EventStreamType` or `EventSourceType` | reads the narrowed tail after `Handle`; changes granularity, not the decision | `On`, explicit scope or stream decision |
165
245
  | Default (no attributes) | Arc reads the tail when the unit of work commits, after `Handle` | only protects the window between that read and the write |
166
- | First append into a scope | unchecked unless `ChronicleOptions.ConcurrencyOptions.CheckFirstAppendIntoAScope` is set | constraint, or an aggregate (new aggregates expect no first event) |
167
- | Two or more key candidates (`EventSourceId`-typed, `[Key]`-marked or implicitly convertible) | `ARCCHR0002` warns, and `[Key]` does not silence it. At runtime Arc silently takes the first eligible property in declaration order (`EventSourceId`-typed or `[Key]`-marked, no priority for derived types; an unmarked `ConceptAs<Guid>` is not eligible). With one candidate Arc keys by it silently; with none each call gets a new id | implement `ICanProvideEventSourceId` |
246
+ | First append into a scope | unchecked unless `ChronicleOptions.ConcurrencyOptions.CheckFirstAppendIntoAScope` is set | constraint, or an aggregate |
247
+ | A `UsingKey(e => e.Member)` projection read | refused (`ReadCannotBeProtected`) | append a fact to the entity the model is keyed by |
248
+ | Two or more key candidates (`EventSourceId`-typed, `[Key]`-marked or implicitly convertible) | `ARCCHR0002` warns, and `[Key]` does not silence it; Arc takes the first eligible property in declaration order | implement `ICanProvideEventSourceId` |
249
+ | Per-stream model on a command without a route | `ReadModelStreamNotOnCommandRoute` (500, analyzer `ARCCHR0037`) | add `[EventSource<TSource>("Stream")]` and `[EventStreamId("{Prop}")]` |
250
+ | Whole-source read beside a per-stream read of the same source | both drop the stream dimensions (Measure reports `coarsened`) | keep one granularity |
168
251
  | No id property | a random event source id per call; nothing can conflict | give the command a key |
169
- | `[Unprotected]` command taking `DecisionRead<T>` | resolves to an unguarded read | use `[ProtectedDecision]` |
170
252
  | Injected `IEventLog.Append` in `Handle` | immediate write that survives a conflict rollback | return events |
171
253
  | Aggregate `Commit()` whose result is ignored, or `Failed()` without `Commit()` | staged events still commit | return `await aggregate.Commit()` |
172
- | Aggregate with an enrolled decision read | `Commit()` throws `ProtectedUnitOfWorkRequiresOwner` | choose one mechanism per command |
173
- | Aggregate for a rule across aggregates | each aggregate guards only itself | cross-key decision reads |
174
- | Tests without `UseDecisionReads()` and a competing `AppendConcurrently` | the race is never exercised | `CommandScenario` with decision reads |
254
+ | `[Unprotected]` command taking `DecisionRead<T>` | resolves to an unguarded read | remove `[Unprotected]` |
255
+ | Tests without `AppendConcurrently`, or a scenario left in legacy mode | the race is never exercised | [test the race](#test-the-race) |
175
256
 
176
257
  ## Chronicle-only traps
177
258
 
178
259
  - Decision read, then plain `IEventLog.Append`: the immediate append bypasses the
179
260
  guard. Stage with `Transactional.Append`, or pass `guardedBy:` to `AppendMany`.
180
261
  - `Get<T>` needs an ambient unit of work. `GetDetached<T>` enrolls nothing until you
181
- pass it to `AppendMany(..., guardedBy:)` or `AddDecisionRead`.
262
+ pass it to `AppendMany(..., guardedBy:)` or `AddDecisionRead`. A unit can also
263
+ call `EnforceReadProtection()` and `AllowReads(ReadAllowance.Advisory|Contributed,
264
+ reason)`; `GetAppendConditions()` returns the `AppendCondition` values
265
+ (`Cratis.Chronicle.Transactions`).
266
+ - Stream reads: `IReadModels.GetInstanceById<T>(EventSourceId, EventStreamId)` throws
267
+ `StreamScopeNotSupported` unless the implementation supports it (analyzer CHR0064).
182
268
  - The ASP.NET Core middleware commits **after** the response. Call
183
269
  `IUnitOfWorkCompletionFeature.CommitAsync()` before responding and check
184
270
  `IsSuccess` and `GetDecisionConflicts()`; the middleware reports no concurrency
@@ -191,22 +277,75 @@ stored tail filtered by key, types and route). See the skill's
191
277
  - Arc has a static class also named `ConcurrencyScopeBuilder`; in an Arc project
192
278
  qualify Chronicle's builder.
193
279
 
194
- ## Costs and why protection is opt-in
280
+ ## Roll out with Measure
281
+
282
+ Steps: update the packages, set `<CratisReadProtection>Measure</CratisReadProtection>`,
283
+ run the application and its tests, read the report, fix or annotate each finding,
284
+ then set `On` per project. Behaviour for callers does not change under `Measure`.
285
+
286
+ - The report: meter `Cratis.Arc.ReadProtection`, counter
287
+ `cratis.arc.read_protection.events` (tags `command`, `category`, `model`,
288
+ `reason`), span event `read-protection.plan`, and a Warning once per command and
289
+ category for `refused_read`, `immediate_append` and `scope_refusal`.
290
+ - Categories: `protected_read`, `refused_read` (reason names the cause),
291
+ `advisory_read`, `unknown_dependency`, `redirect` (add `[LookUpBy]`),
292
+ `immediate_append` (return the event), `conflict_today`, `self_write`,
293
+ `coarsened`, `displacement`, `scope_refusal`, `matching_events_since_start`
294
+ (someone else's matching event arrived mid-command: `On` might have returned
295
+ 400, a real race), `measurement_unavailable`, `mode_lowered`.
296
+ - Measure is best effort: one extra awaited call per command plus sampled
297
+ background scans. A report is a set of hypotheses.
298
+ - Fixes: reducer with dependencies or filters, join or children, event-property
299
+ key, Pinned delivery or `IMongoCollection`: change the read as the refusal says,
300
+ or annotate `[Unprotected("why")]`. Under `On`, `ARCCHR0011` no longer applies;
301
+ fix `ARCCHR0024`, `0028` and `0033`.
302
+
303
+ ## Test the race
304
+
305
+ `new CommandScenario<T>()` selects the in-process decision log from the command's
306
+ read-protection mode and dependencies (for example under `On`); do not call
307
+ `UseDecisionReads()` (it is hidden and unnecessary). An `Off` command with no
308
+ protected dependency stays in legacy mode with pinned read models, which cannot
309
+ prove a race. Seed with
310
+ `scenario.Given.ForEventSource(id).Events(...)` (for a routed stream
311
+ `.OnRoute(route).Events(...)`). Guarded state needs events: protected reads fold the
312
+ event log and ignore a pinned model, and `DecisionRead<T>` parameters reject one.
313
+ `.ReadModel(m)` only pins an advisory (plain) read. Then queue a competing event with
314
+ `scenario.AppendConcurrently(id, events...)` and execute:
315
+
316
+ ```csharp
317
+ await using var scenario = new CommandScenario<BorrowBook>();
318
+ scenario.Given.ForEventSource(isbn).Events(new BookAdded(Copies: 1));
319
+ scenario.AppendConcurrently(isbn, new BookBorrowed(other));
320
+ var result = await scenario.Execute(new BorrowBook(isbn, me));
321
+ result.ShouldHaveValidationErrorBecauseOf(ValidationResultReason.ConcurrencyViolation);
322
+ ```
323
+
324
+ There is no `scenario.AppendConditions`. A pure decision test builds the command
325
+ and the validator with a hand-made read model. Chronicle-direct code asserts
326
+ `unitOfWork.GetAppendConditions()`. A default `EventScenario` scope strategy
327
+ returning `None` is not concurrency evidence.
328
+
329
+ ## Costs
195
330
 
196
- A decision read makes about four calls per attempt (up to three attempts), folds the
197
- key's whole history of the model's types on every read, shares a per-model-type
331
+ A guarded read makes about four calls per attempt (up to three attempts), folds
332
+ the key's whole history of the model's types on every read, shares a per-model-type
198
333
  projection grain across keys and tenants, and is not retried on conflict. Use it
199
334
  where histories are short and write contention per key is low; shorten histories
200
335
  before reaching for it on a hot key. A constraint costs one index lookup inside the
201
- append. Decision reads are .NET only.
336
+ append. Decision reads exist in the .NET and Elixir clients.
202
337
 
203
338
  ## Checklist for generated code
204
339
 
205
- - Name the mechanism and say why the rule shape needs it. A plain read, validator,
206
- `Result<,>` or `concurrency: true` is never presented as the guard.
207
- - Uniqueness is a constraint. A state rule has a decision read, scope or stream
208
- decision enrolled **before** the events are staged.
209
- - The decision-read model is admitted (flat projection, `Id`, event-source keyed).
210
- - Cross-key rules append one event per source atomically.
211
- - A specification interleaves a competing append and asserts the conflict.
212
- - What stays unguarded (an unguarded writer's own decision, external state, revisions) is stated.
340
+ - Name the mechanism and say why the rule shape needs it. A plain read, a
341
+ validator under `Off`, `Result<,>` or `concurrency: true` is never presented as
342
+ the guard.
343
+ - New code sets `On`. Uniqueness is a constraint. A state rule is a read-model
344
+ parameter in a validator, a per-stream model, a stream decision, an aggregate or
345
+ an explicit scope, enrolled **before** the events are staged.
346
+ - The model is admitted (source-keyed projection or admitted reducer, `Id`).
347
+ - Cross-key rules append one event per source atomically, with distinct event types.
348
+ - A specification interleaves a competing append and asserts
349
+ `ConcurrencyViolation`.
350
+ - What stays unguarded (an unguarded writer's own decision, external state,
351
+ revisions, `[Unprotected]` reads, `Off` mode) is stated.
@@ -15,7 +15,7 @@ Constraints are discovered automatically. There is no registration call.
15
15
 
16
16
  A constraint binds **every** writer, whichever client or service appends, so it is
17
17
  the mechanism for uniqueness and create-once. It cannot count, sum or test a
18
- predicate: capacity and stock rules need a decision read, a stream decision or an
18
+ predicate: capacity and stock rules need a guarded read (read protection `On`), a stream decision or an
19
19
  aggregate. Read [choosing how a rule holds](../cratis-chronicle-decision-reads/references/choosing-how-a-rule-holds.md)
20
20
  when it is unclear which one a rule needs. Limits: the claim is settled after the
21
21
  durable append, so a failed index update can let a later duplicate
@@ -43,9 +43,9 @@ memory.
43
43
  - The rule rejects **command input** — a range, a format, a cross-field
44
44
  condition: use `cratis-arc-command-validation`.
45
45
  - The rule is state-dependent (a count, a capacity, a status) and needs
46
- concurrency protection: use enrolled stream/projection decision reads with
47
- `cratis-arc-command-validation` and `cratis-chronicle-decision-reads`. Plain
48
- injected models, validators and `Result<,>` alone remain advisory.
46
+ concurrency protection: use read protection `On` or stream decision reads with
47
+ `cratis-arc-command-validation` and `cratis-chronicle-decision-reads`. Injected
48
+ models and validators under `Off` (the default), and `Result<,>` alone, remain advisory.
49
49
  - You are creating or changing a read model that the rule reads: use
50
50
  `cratis-chronicle-read-model`.
51
51
  - You are writing the specification and want the `EventScenario` mechanics in
@@ -156,14 +156,16 @@ If a field can only be filled by reaching into another behavior's read model to
156
156
  a wrong stream boundary. Fix the model. A read-only cross-slice read through
157
157
  Chronicle is allowed where the rule needs it (an injected read model read by
158
158
  explicit key, or a `[Passive]` projection for a current read; a rule that must hold under
159
- concurrency needs a decision read or constraint, see
159
+ concurrency needs read protection `On`, a stream decision or a constraint, see
160
160
  [choosing how a rule holds](../cratis-chronicle-decision-reads/references/choosing-how-a-rule-holds.md)); see
161
161
  Cross-slice patterns in `rules/vertical-slices.md`. Make such a read deliberate
162
162
  and name it in the brief, with the consistency it needs: a rule that must hold
163
163
  under concurrency needs enrolled guarded reads, not plain injection or a stale
164
- materialized snapshot. In Arc v22.75.0 use `IStreamReads`/`StreamDecision` for one
165
- stream or `[ProtectedDecision]` + admitted `DecisionRead<T>` for projected state;
166
- `Result<,>` alone supplies rejection, not a concurrency guard.
164
+ materialized snapshot. In Arc use read protection `On` with the read model as a
165
+ validator or `Handle()` parameter for projected state, a per-stream read model or
166
+ `IStreamReads`/`StreamDecision` for one stream (`[ProtectedDecision]` +
167
+ `DecisionRead<T>` is the older per-command opt-in); `Result<,>` alone supplies
168
+ rejection, not a concurrency guard.
167
169
 
168
170
  ## Compliance modeling
169
171
 
@@ -129,7 +129,8 @@ builder when switching and verify storage/replay separately from output equality
129
129
  Read [projection behavior](../cratis-chronicle-projection/SKILL.md) for mappings
130
130
  and [reducers](../cratis-chronicle-reducer/SKILL.md) for complete signatures,
131
131
  null deletion, replay hooks and preserving admission/ordering guards. Reducers
132
- are source-keyed, have no native joins and are refused by decision reads.
132
+ are source-keyed and have no native joins; decision reads and Arc read protection `On`
133
+ admit only unfiltered reducers without constructor dependencies.
133
134
 
134
135
  ## Step 4 — Add a query surface, if the model is read from outside
135
136
 
@@ -214,9 +214,12 @@ model type** and makes it passive as a whole: no sink, nothing materialized, and
214
214
  the instance is computed on demand when it is read. Reach for it when a
215
215
  command-side read needs the model current at the moment it is read. Passive is
216
216
  fresher, not safer: a passive reducer read captures no position and guards nothing,
217
- and a reducer (or a self-reducing model) is refused by
218
- decision reads. For a rule that must hold under concurrent writers use a flat
219
- projection with a decision read; see
217
+ and a plain read of a reducer guards nothing. An unfiltered reducer without
218
+ constructor dependencies (other than `ILogger<T>`; self-reducing models are exempt)
219
+ is admitted by decision reads and by Arc read protection `On` (Chronicle 19.68.0);
220
+ a filtered reducer or one with dependencies is refused, and no reducer is admitted in
221
+ a stream-scoped read. For a rule that must hold under concurrent writers read an
222
+ admitted model under `On`; see
220
223
  [choosing how a rule holds](../cratis-chronicle-decision-reads/references/choosing-how-a-rule-holds.md).
221
224
 
222
225
  ## Discovery, upgrades and ownership
@@ -81,10 +81,13 @@ there is nothing to review under a red build.
81
81
  - Logging lives in a `*Logging.cs` partial with `[LoggerMessage]`, not inline in
82
82
  domain code.
83
83
  - No shared mutable state between commands.
84
- - Plain injected models are advisory, not a concurrency guard. Review guarded
85
- stream/projection reads and actual conflict specs (`UseDecisionReads()`).
84
+ - Read protection is `Off` by default; new code sets `<CratisReadProtection>On</CratisReadProtection>`.
85
+ Under `Off` an injected model or validator read is advisory, not a concurrency guard.
86
+ Under `On`, review that state rules sit in validators or handlers with the model as
87
+ a parameter, `[Unprotected("why")]` carries a real reason, and a conflict spec
88
+ (`AppendConcurrently` plus `ConcurrencyViolation`) exists.
86
89
  - Use `Cratis.Arc.Core.CodeAnalysis` and `Cratis.Arc.Chronicle.CodeAnalysis`.
87
- ARCCHR0011/0012 (Info) flag unprotected reads/immediate
90
+ ARCCHR0011 (Info, `Off` only) and 0012 flag unprotected reads/immediate
88
91
  bypasses; 0013 (Info) recommends definitions; 0014 checks declared observer
89
92
  streams; 0015 warns on nullable events; 0016 flags ignored concurrency flags;
90
93
  0017/0018 check stream-id templates; 0019 warns on folds exposed as queries.