Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
13f0cfa
Align tenancy docs with the actual default tenant header
claude Jul 29, 2026
a982591
Check the documentation links instead of reporting success on nothing
woksin Jul 29, 2026
ffe33e5
Point the documentation links at the pages they name
woksin Jul 29, 2026
c43246b
Read the tags a declaration carries in one place
woksin Jul 29, 2026
6982319
Report what an application says about how it is served
woksin Jul 29, 2026
6d9d5f0
Say that everything the document leaves out is now reported
woksin Jul 29, 2026
b5d9099
Add provider-agnostic read model command resolution to Arc.Core
claude Jul 29, 2026
bec2118
Refactor Chronicle read model resolution onto the shared abstraction
claude Jul 29, 2026
aa346fc
Make EF Core read models injectable into commands by key
claude Jul 29, 2026
8159fde
Add specs for command-scoped read model resolution and EF injection
claude Jul 29, 2026
669f7d4
Document injecting non-Chronicle read models into commands
claude Jul 29, 2026
4b3298d
Resolve EF read models asynchronously and complete spec doc
claude Jul 29, 2026
9a50454
Merge branch 'main' into claude/issue-2303-nonchronicle-readmodels
woksin Jul 29, 2026
63edf79
Merge claude/issue-2351-tenant-resolution
woksin Jul 29, 2026
43c5083
Merge fix/verify-markdown-zero-links
woksin Jul 29, 2026
9cee374
Merge feat/screenplay-report-transport-concerns
woksin Jul 29, 2026
df94b67
Merge claude/issue-2303-nonchronicle-readmodels
woksin Jul 29, 2026
5c9f4e1
Merge chronicle-update/16.10.0
woksin Jul 29, 2026
dab8bc5
Decide which provider resolves a read model by declared ownership
woksin Jul 29, 2026
166db79
Add specs for read model ownership between providers
woksin Jul 29, 2026
8d8a648
Inject a read model held in MongoDB into a command
woksin Jul 29, 2026
ee8e2af
Add specs for resolving a MongoDB read model for a command
woksin Jul 29, 2026
e62f190
Document injecting a MongoDB read model and which provider wins
woksin Jul 29, 2026
a1c611e
Report the queries and routes SP0041 was still passing over
woksin Jul 29, 2026
06d71ba
Keep the link check honest about what it scanned
woksin Jul 29, 2026
3ea7314
Implement the event store members Chronicle 16.10.0 added
woksin Jul 29, 2026
1135fa0
Build a pull request that changes what the build compiles against
woksin Jul 29, 2026
44e7b97
Merge main
woksin Jul 30, 2026
230c671
Establish MongoDB the way an application does in the resolver specs
woksin Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/dotnet-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ on:
paths:
- "Source/DotNET/**"
- "Specifications/**"
- "TestApps/**"
# What the build compiles against, not only what it compiles. A package
# version bump touches none of the paths above, so it used to reach main
# without ever having been built - which is how a Chronicle release that
# added members to an interface Arc implements got merged unbuilt.
- "Directory.Build.props"
- "Directory.Packages*.props"
- "global.json"
- "Arc.slnx"
- ".github/workflows/dotnet-build.yml"

jobs:
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/markdown-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

- name: Check links in Documentation
uses: JustinBeckwith/linkinator-action@v1
- name: Setup Node
uses: actions/setup-node@v4
with:
paths: 'Documentation/**/*.md'
markdown: true
recurse: true
redirects: allow
statusCodes: '403:ok'
linksToSkip: '^(https?:\/\/)?(localhost|127\.0\.0\.1)(:\d+)?(\/|$)'
node-version: 23.x

# The same script contributors run locally, so that what CI checks and
# what verify-markdown.sh checks cannot drift apart.
- name: Check links in Documentation
run: ./Documentation/verify-links.sh
2 changes: 1 addition & 1 deletion Documentation/backend/asp-net-core/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ builder.AddCratisArc(options =>
options.CorrelationId.HttpHeader = "X-My-Correlation-ID";

// Configure tenancy
options.Tenancy.HttpHeader = "X-Tenant-ID";
options.Tenancy.HttpHeader = "X-Custom-Tenant";

// Configure generated APIs
options.GeneratedApis.RoutePrefix = "myapi";
Expand Down
2 changes: 1 addition & 1 deletion Documentation/backend/chronicle/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Without this package, Arc and Chronicle are independent. With it:
| Topic | Description |
| ----- | ----------- |
| [Aggregates](aggregates/index.md) | Working with aggregate roots and event sourcing. |
| [Add event sourcing to an Arc slice](add-event-sourcing.md) | Move one database-backed slice to Chronicle while keeping its query and React screen in place. |
| [Add event sourcing to an Arc slice](add-event-sourcing.mdx) | Move one database-backed slice to Chronicle while keeping its query and React screen in place. |
| [Cratis Package](cratis-package.md) | The convenience package for Arc + Chronicle applications. |
| [React to an event](react-to-an-event.md) | Run side effects or follow-up commands from Chronicle events with reactors. |
| [Commands](commands/index.md) | Returning events from commands, event source id resolution, and concurrency scoping. |
Expand Down
2 changes: 1 addition & 1 deletion Documentation/backend/chronicle/react-to-an-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@ A reactor may be called more than once for the same event — during replay or r
## See also

- [Returning Commands as Side Effects](./reactors/toc.yml) — execute commands as side effects from a reactor.
- [Add event sourcing to an Arc slice](./add-event-sourcing.md) — where reactors enter the Arc model.
- [Add event sourcing to an Arc slice](./add-event-sourcing.mdx) — where reactors enter the Arc model.
- [Return a result or an error](/arc/scenarios/return-a-result-or-error/) — what the command you execute can return.
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,77 @@ public record AddItemToCart([Key] Guid CartId, Guid ProductId, int Quantity)

Read models never emit events. If the decision must hold under concurrency, drive it from the aggregate or from a Chronicle [constraint](/chronicle/constraints/) rather than from projected state — read models are eventually consistent.

## Read models from other providers

Injection is not Chronicle-only. Any provider that owns a read model's storage can make its `[ReadModel]` types injectable into a command, resolved by the same key, so a validator, `Provide()`, or `Handle()` takes the read model exactly as it would a Chronicle-backed one.

### Entity Framework Core

A `[ReadModel]` entity carried by a `ReadOnlyDbContext` becomes injectable once the context is registered — there is nothing extra to wire up:

```csharp
[ReadModel]
public class Customer
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
}

public class CustomerDbContext(DbContextOptions<CustomerDbContext> options) : ReadOnlyDbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
}
```

```csharp
[Command]
public record RenameCustomer([Key] Guid CustomerId, string NewName)
{
public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName);
}
```

`WithEntityFrameworkCore()` discovers the `ReadOnlyDbContext`, and the command's resolved key (here the `[Key]` on `CustomerId`) loads the entity by its primary key. The primary key may be a `Guid`, `int`, `long`, `string`, or a `ConceptAs<T>` wrapping one of those.

The nullable rules are identical: a nullable `Customer?` receives `null` when no row exists, and a non-nullable `Customer` fails the command with [`ReadModelDoesNotExistForCommand`](./failures.md#readmodeldoesnotexistforcommand).

### MongoDB

`WithMongoDB()` does the same for the read models MongoDB holds. There is nothing to declare — a `[ReadModel]` becomes injectable, resolved by the document `_id`:

```csharp
[ReadModel]
public record Customer(Guid Id, string Name)
{
public static IEnumerable<Customer> AllCustomers(IMongoCollection<Customer> collection) =>
collection.Find(_ => true).ToList();
}
```

```csharp
[Command]
public record RenameCustomer([Key] Guid CustomerId, string NewName)
{
public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName);
}
```

The id member is whichever one MongoDB maps to `_id` — a member named `Id` by convention, or the one marked `[BsonId]`. Like the EF primary key it may be a `Guid`, `int`, `long`, `string`, or a `ConceptAs<T>` wrapping one of those. A read model with no member mapped to `_id` cannot be resolved by key, and injecting it fails with `MissingIdMapping`.

### Which provider resolves a read model

More than one provider can be able to load the same read model, and the order an application registers them in should not decide the outcome. What decides it is whether an artifact in the application says the provider *owns* the read model:

| Provider | Owns a read model when | Claims it as |
|---|---|---|
| Chronicle | a projection, model-bound projection, or reducer targets it | declared |
| Entity Framework Core | a `DbSet` on a `ReadOnlyDbContext` carries it | declared |
| MongoDB | — a collection is served for any read model | fallback |

A declaring provider always wins, in either registration order. MongoDB claims only what nothing else resolves, and it also leaves your own registration of a read model type alone. This matters beyond tidiness: Chronicle is the provider that releases a read model's compliance-protected values, so a read model Chronicle projects has to be resolved by Chronicle.

To contribute a provider of your own, implement `ICanResolveReadModelForCommand` — reporting the types it resolves, the `ReadModelForCommandOwnership` it claims them with, and how to load one by key — and register it with `services.AddReadModelsForCommand(...)`.

## Testing

Seed the state the command should see with the `Given` builder — either the events behind it or a pinned instance — and execute through the real pipeline. See [Testing with Chronicle](../../testing/chronicle.md#testing-commands-that-take-read-model-dependencies).
Expand Down
2 changes: 1 addition & 1 deletion Documentation/backend/core/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ Arc.Core supports standard .NET configuration:
"HttpHeader": "X-Correlation-ID"
},
"Tenancy": {
"HttpHeader": "X-Tenant-ID"
"HttpHeader": "X-Custom-Tenant"
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion Documentation/backend/getting-started/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Get an Arc backend up and building features. These first pages use a plain datab

Once you have a project:

- **[Your first command and query](./your-first-command.md)** — build a backend slice end to end: a command with `Handle()`, the read model it writes, and the live query that serves it.
- **[Your first command and query](./your-first-command.mdx)** — build a backend slice end to end: a command with `Handle()`, the read model it writes, and the live query that serves it.
- **[MongoDB integration](../mongodb/index.md)** — configure Arc over MongoDB collections and observable change streams.
- **[Entity Framework integration](../entity-framework/getting-started.md)** — configure Arc over DbContexts and observed DbSets.

Expand Down
2 changes: 1 addition & 1 deletion Documentation/backend/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,5 @@ Arc meets the rest of your stack:
| [Open API](./open-api/index.md) | OpenAPI/Swagger generation. |
| [Code Analysis](./code-analysis/index.md) | Analyzers and fixers that catch mistakes at compile time. |

Building the UI on top? Head to the [frontend](../frontend/index.md), which consumes everything here
Building the UI on top? Head to the [frontend](../frontend/index.mdx), which consumes everything here
through the generated proxies.
2 changes: 1 addition & 1 deletion Documentation/backend/tenancy/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ builder.AddCratisArcCore(options =>
"Arc": {
"Tenancy": {
"ResolverType": "Header",
"HttpHeader": "X-Tenant-ID"
"HttpHeader": "X-Custom-Tenant"
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions Documentation/backend/tenancy/resolvers.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Resolves the tenant ID from an HTTP header.
```csharp
builder.AddCratisArcCore(options =>
{
options.UseHeaderTenancy("X-Tenant-ID");
options.UseHeaderTenancy("X-Custom-Tenant");
});
```

Expand Down Expand Up @@ -50,11 +50,11 @@ Resolves the tenant ID from the first segment of the request hostname. When the
```csharp
builder.AddCratisArcCore(options =>
{
options.UseSubdomainTenancy("X-Tenant-ID");
options.UseSubdomainTenancy("X-Custom-Tenant");
});
```

A request to `acme.myapp.com` resolves the tenant as `acme`. A request to `myapp.com` falls back to the `X-Tenant-ID` header. This pattern is useful for SaaS applications where each tenant is routed through its own subdomain.
A request to `acme.myapp.com` resolves the tenant as `acme`. A request to `myapp.com` falls back to the `X-Custom-Tenant` header. This pattern is useful for SaaS applications where each tenant is routed through its own subdomain.

Default fallback header: `x-cratis-tenant-id`

Expand Down
2 changes: 1 addition & 1 deletion Documentation/frontend/react/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ those two with validation, scopes, and identity.
| [Identity](./identity.md) | Who the user is, and what they're allowed to see and do. |
| [Dialogs](./dialogs.md) | Consistent dialog handling for command and data-entry flows. |
| [Proxy Generation](../../backend/proxy-generation/index.md) | How the typed proxies you import here are generated from C#. |
| [Storybook](./storybook.md) | The Storybook for the components Arc exposes. |
| [Storybook](./storybook.mdx) | The Storybook for the components Arc exposes. |
| [Story Components](./stories) | Building good-looking, consistent stories. |

Prefer a structured, testable approach for complex screens? See [MVVM with React](../react.mvvm/index.md).
6 changes: 3 additions & 3 deletions Documentation/generating-a-screenplay.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ These are part of the language, but nothing in C# says them, so a generated docu

### Detail Screenplay cannot represent

These exist in your application and do not reach the document — most of them because the language has no counterpart. Almost all are reported as a diagnostic when encountered, so the document tells you it is silent about them. The two rows naming no code are the exception: nothing is reported for those.
These exist in your application and do not reach the document, because the language has no counterpart. Each is reported as a diagnostic when encountered, so the document tells you it is silent about them.

| In Arc or Chronicle | Why it is not in the document |
|---|---|
Expand All @@ -251,8 +251,8 @@ These exist in your application and do not reach the document — most of them b
| Inline `policy` code and requirements built in code | `RequireAssertion(…)` and a policy registered from an `AuthorizationPolicy` built elsewhere are code. `RequireAuthenticatedUser`, `RequireRole`, and `RequireClaim` given the values it accepts are recovered; the rest is reported as `SP0026` — including a `RequireClaim` naming only a claim type, which a policy condition has no way to state. |
| The event source id from a `(TKey, TEvent)` handler | The event is recovered; the identifier saying *which* event source it goes to has no counterpart. `SP0013`. |
| Emptying a scope with `[ClearWith]`; removing a child with `[RemovedWith]` on the property holding it | Nothing in the model a projection is built from carries a scope being emptied again, so `[ClearWith]` has nowhere to go (`SP0015`). A removal does have somewhere — but it is read from the type of the child, alongside the events filling that child in, so the same removal written beside the collection is reported as `SP0007` instead. |
| Read model tags | A read model has no declaration of its own — it appears as the type a query returns — so there is nowhere to hang a tag. Tags on *events* are recovered and written out. |
| Query paging and sorting, custom routes | These say how a model is served rather than what it is. The parameters the host fills in are left out, and a route template has no counterpart. |
| Read model tags | A read model has no declaration of its own — it appears as the type a query returns — so there is nowhere to hang a tag. Tags on *events* are recovered and written out. `SP0042`. |
| Query paging and sorting, custom routes | These say how a model is served rather than what it is. The parameters the host fills in are left out, and a route template — `[Path]`, `[Route]`, or a template on an HTTP verb — has no counterpart. `SP0041`. |

If a generated `.play` is missing something you expected, the diagnostics are the first place to look — the omission is almost always reported.

Expand Down
2 changes: 1 addition & 1 deletion Documentation/tutorial/books-and-relationships.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,4 @@ The `authorId` is context the form needs to be *valid* from the start, so it goe
- A **relationship as a foreign key + a filtered query**: `BooksForAuthor` reads exactly one author's catalog, live, with no join.
- A React screen that composes the two: a list of authors, each rendering its own live catalog.

Two features, related, both live. We keep *saying* "it stays live" — next we'll make that concrete and watch a screen update itself the instant the data changes. [Let's make it live →](./real-time)
Two features, related, both live. We keep *saying* "it stays live" — next we'll make that concrete and watch a screen update itself the instant the data changes. [Let's make it live →](./real-time.mdx)
2 changes: 1 addition & 1 deletion Documentation/tutorial/first-slice.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -255,4 +255,4 @@ In one folder, read top to bottom:

That's a complete vertical slice, backend to browser, over a plain database. The next feature will be another folder just like it.

There's one problem, though: right now a librarian can register an author with a blank name, or the same author twice, and nothing stops them. A real app has to say no. [Let's make it trustworthy →](./validation)
There's one problem, though: right now a librarian can register an author with a blank name, or the same author twice, and nothing stops them. A real app has to say no. [Let's make it trustworthy →](./validation.mdx)
12 changes: 6 additions & 6 deletions Documentation/tutorial/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ An Arc project running locally with a database. The [Get started](/arc/backend/g

## The tour

1. **[Your first full-stack slice](./first-slice)** — register an author from C# all the way to a live React screen, fully typed.
2. **[Make it trustworthy](./validation)** — reject bad input with a validator and a uniqueness rule, and show the reason in the form.
3. **[Relate your slices](./books-and-relationships)** — add books that belong to an author, and read them back.
4. **[Make it live](./real-time)** — observable queries that update the screen the moment the data changes.
5. **[Decide who can do what](./authorization)** — lock the catalog down with role-based authorization.
1. **[Your first full-stack slice](./first-slice.mdx)** — register an author from C# all the way to a live React screen, fully typed.
2. **[Make it trustworthy](./validation.mdx)** — reject bad input with a validator and a uniqueness rule, and show the reason in the form.
3. **[Relate your slices](./books-and-relationships.mdx)** — add books that belong to an author, and read them back.
4. **[Make it live](./real-time.mdx)** — observable queries that update the screen the moment the data changes.
5. **[Decide who can do what](./authorization.mdx)** — lock the catalog down with role-based authorization.

Each chapter ends where the next begins. By the end you'll have a real full-stack feature — and the model to build your own. Ready? [Let's build the first slice →](./first-slice)
Each chapter ends where the next begins. By the end you'll have a real full-stack feature — and the model to build your own. Ready? [Let's build the first slice →](./first-slice.mdx)
2 changes: 1 addition & 1 deletion Documentation/tutorial/real-time.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,4 @@ That's the whole switch between "load once" and "stay live." You choose per quer
- A clear picture of **why your screens update themselves** — observable queries are standing subscriptions, database to browser.
- The one-line difference between a **one-shot** query and a **live** one — the return type, nothing else.

The catalog is live. There's just one thing left before it's a real back office: right now *anyone* can register authors and add books. Let's decide who's allowed. [Lock it down →](./authorization)
The catalog is live. There's just one thing left before it's a real back office: right now *anyone* can register authors and add books. Let's decide who's allowed. [Lock it down →](./authorization.mdx)
2 changes: 1 addition & 1 deletion Documentation/tutorial/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,4 @@ One rule on the value type, one rule on the command, and both ends of the app ho
- A `RegisterAuthorValidator` **business rule** that checks existing state in the database, with a unique index behind it for the hard guarantee.
- A React form that surfaces both — client-side and server-side — with no extra frontend code.

Our author is trustworthy now. But an author with no books is a lonely thing. Next we'll give them a catalog — a second feature that *relates* to this one. [Let's add books →](./books-and-relationships)
Our author is trustworthy now. But an author with no books is a lonely thing. Next we'll give them a catalog — a second feature that *relates* to this one. [Let's add books →](./books-and-relationships.mdx)
Loading
Loading