Skip to content

Inject read models from any provider, and ship the open work as one release - #2425

Open
woksin wants to merge 28 commits into
mainfrom
combined/one-release
Open

Inject read models from any provider, and ship the open work as one release#2425
woksin wants to merge 28 commits into
mainfrom
combined/one-release

Conversation

@woksin

@woksin woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

The five open pull requests are merged here so they ship as one release rather than five.

Added

Changed

  • Cratis.Chronicle, Cratis.Chronicle.AspNetCore and Cratis.Chronicle.Testing are updated from 16.9.1 to 16.10.0, picking up 16.9.2, 16.9.3, 16.9.4 and 16.10.0
  • Chronicle 16.9.2: CHR0038 now also reports the fluent builder.Join<TEvent>(_ => _.On(...)) form, not just the model-bound [Join<TEvent>] attribute. A [PII] value counts as reaching the read model through an explicit .Set(...).To(...) mapping or through AutoMap matching an identically named property.
  • Chronicle 16.10.0: Registering event types on connect issues one read and one bulk write for the whole batch instead of several round trips per event type, so startup no longer scales its database round trips with the number of event types. (Optimize event type registrations on server with registering all event types instead of singles Chronicle#984)

Fixed

claude and others added 28 commits July 29, 2026 12:02
The documentation examples used 'X-Tenant-ID', which implied it was the
default or standard tenant header. The real default is 'x-cratis-tenant-id',
so the examples now use the 'X-Custom-Tenant' placeholder already used
elsewhere to make clear these are custom overrides.
The skip pattern excluded every URL on localhost, which is where linkinator
serves the files it scans. It matched the crawl root itself, so the whole
scan was skipped and each run reported success having checked zero links —
locally and in CI alike.

- Skip only what falls outside /Documentation/, which is exactly the
  site-absolute paths a single repository cannot resolve.
- Fail when a run scans zero links or reports no summary at all, so the
  check cannot quietly become a no-op again.
- Scan .mdx pages as well, and let CI run the same script contributors run,
  so the two configurations cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Five links kept the .md extension after the page they name became .mdx, so
they resolved nowhere at all. Nine more left the extension off; the rest of
the documentation links to a page by its file name, and now these do too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Chronicle tags observers, read models and event types alike, and only the
event had somewhere to put them - so the reading lived where the event was
read. Nothing about it is particular to events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
The generator reports everything it cannot express, so that a reader can tell
an application that does not do something from a generator that could not say
it. Three things broke that and were passed over without a word: a query the
host pages or sorts, a route template declared with [Path], [Route] or on an
HTTP verb, and the tags a read model carries - while an event's tags are
printed, which makes their absence read like the application's own.

Paging, sorting and routes share SP0041: all three say how a caller reaches
the application rather than what it is, so a reader who does not want to hear
about it suppresses one code. Tags take SP0042, because the reason differs -
a read model is named in the document only as what a query answers with, so
what it carries beyond its shape has nowhere to go at all.

The plumbing a host fills in of its own - a cancellation token, a query
context - is still passed over silently. Neither says anything about the
application.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Introduce ICanResolveReadModelForCommand so any backing provider can
contribute a command-scoped, by-key read model resolver, and lift the
read model injection support out of Chronicle into Arc.Core:

- ICanResolveReadModelForCommand abstraction and AddReadModelsForCommand
  registration that registers a scoped by-key factory per read model
  type and keeps an additive union of registered types across providers.
- A provider-neutral resolved key on the command context (CommandContextKeys
  plus a GetResolvedKey accessor).
- RegisteredReadModelTypes, the unresolvable-dependency classifier, and the
  ReadModelDoesNotExistForCommand / UnableToResolveReadModelFromCommandContext
  validation failures, now keyed off the neutral resolved key.
Chronicle now contributes its read model types through the Arc.Core
ICanResolveReadModelForCommand seam instead of owning the registration
and classification itself:

- ChronicleReadModelForCommandResolver wraps the existing by-key
  resolution through IReadModels; AddReadModels registers it via
  AddReadModelsForCommand. No change to how Chronicle resolves or
  releases read models.
- EventSourceValuesProvider also writes the provider-neutral resolved
  key alongside the event source id, so non-Chronicle providers can
  resolve by the same key. Existing event-source-id behavior is unchanged.
- The moved RegisteredReadModelTypes, classifier, and validation failures
  are consumed from Arc.Core.
Add EntityFrameworkReadModelForCommandResolver, which resolves a
[ReadModel] entity from its owning ReadOnlyDbContext by the command's
resolved key. It discovers the read model entity -> DbContext map from
the registered read model contexts, converts the resolved key string to
the entity's primary key type (Guid, int, long, string, or a ConceptAs
of those), and loads the instance with DbContext.Find, returning null
when it does not exist. The resolver is registered automatically when
read model DbContexts are added.
- Arc.Core: registration behavior (scoped factory + additive type union
  across two providers), by-key resolution present/absent, and the
  unresolvable-dependency classifier keyed off the neutral resolved key.
- EF Core (SQLite in-memory): read model type discovery, by-key resolve
  present/absent, missing-key validation failure, and injection of an EF
  [ReadModel] through the command DI resolution seam.
Explain that EF Core [ReadModel] entities are injectable into a command
validator, Provide(), or Handle() by key, with the same nullable rules,
and that Chronicle-backed and EF-backed read models coexist.
- Await DbContext.FindAsync instead of the blocking Find (CA1849/MA0042)
- Add missing param docs to the test resolver context (RCS1141)
More than one provider can load the same read model, and until now the
order an application happened to register them in decided which one did.
A provider now states whether an artifact in the application says it owns
the read model (Chronicle a projection or reducer, EF Core a DbSet) or
whether it can merely load any of them, and a declaring provider wins in
either order. A fallback provider also leaves an application's own
registration of a read model type alone.

This matters before a store that can serve any read model is added:
Chronicle is the only provider that releases a read model's
compliance-protected values, so a read model it projects has to stay with
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
A [ReadModel] whose documents live in MongoDB can now be taken as a
dependency by a command's Handle(), Provide(), or CommandValidator<>, the
same way a Chronicle- or Entity Framework Core-backed one can, resolved by
the document _id from the command's key.

MongoDB serves a collection for any read model without being told about it,
so it claims them as a fallback: a provider that owns a read model keeps
it, and so does an application that registered the type itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
SP0041 read paging and sorting off a query's parameters, which Arc never
fills in - the page and the order travel on the query context. What marks
a query as paged is handing back a queryable, and that is what Arc itself
reads, so the canonical paged query went unreported.

A route was read only from the symbol it was written on, and only the
first of them, while ASP.NET Core honors a template on a base controller
or an overridden action and allows the route and verb attributes to be
applied more than once. Each of those was a route served and never
mentioned - the silence SP0041 exists to break.

A path on a query replaces the read model's rather than extending it, so
naming the read model's path when every query overrides it claimed a route
the application does not serve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
One glob for both extensions: linkinator aborts on a glob matching
nothing, so the separate .mdx glob would fail a repository whose pages are
all .md while every link in it is fine.

The link count is read after stripping the color codes. linkinator wraps
it in them whenever FORCE_COLOR is set, which left the count unreadable
and reported a real link failure as globs that matched nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Chronicle 16.10.0 adds IEventStore.Identities and IEventStore.ExternalServices,
which the in-memory event store behind a command scenario did not implement -
so the build broke the moment the package was updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
A package version bump touches none of the paths the .NET build watched, so
it reached main without ever having been built. That is how the Chronicle
16.10.0 update was merged with a broken build: the release added members to
an interface Arc implements, and only CodeQL ever ran on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
@woksin woksin added the minor label Jul 29, 2026
@woksin

woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

What this supersedes

This branch is origin/main with #2420, #2421, #2422, #2423 and #2424 merged in (all five merged clean, no conflicts), plus the work below. Those five should be closed rather than merged if this one lands.

Why the Chronicle update needed a fix, and why nobody saw it

#2424 on its own does not build. Chronicle 16.10.0 adds IEventStore.Identities and IEventStore.ExternalServices, and Chronicle.Testing's in-memory EventStoreForScenario implements IEventStore — two CS0535s the moment the package moves.

It went unnoticed because .NET Build only triggered on Source/DotNET/**, Specifications/** and its own workflow file. #2424 changes Directory.Packages.props alone, so the only workflow that ever ran on it was CodeQL. The workflow now also watches Directory.Build.props, Directory.Packages*.props, global.json, Arc.slnx and TestApps/** — what the build compiles against, not only what it compiles. Worth knowing that every past dependency-bump PR merged unbuilt for the same reason.

Design changes to #2423

Two providers can be able to load the same read model, and AddReadModelsForCommand decided the winner by registration order (RemoveAll then AddScoped, last one wins). With MongoDB added that stops being cosmetic: MongoDB serves a collection for any [ReadModel], including every read model Chronicle projects — and Chronicle is the only provider that releases compliance-protected values, so a Mongo-resolved Chronicle read model would hand [PII] back still encrypted.

A resolver now declares a ReadModelForCommandOwnership. Chronicle and EF Core are Declared (a projection/reducer targets it; a DbSet carries it) and claim their types outright. MongoDB is Fallback and claims only types nothing else resolves — including skipping a type the application registered itself, which the previous unconditional RemoveAll would have silently dropped. A declaring provider therefore wins in either registration order; specs cover both orderings.

Documentation/backend/chronicle/read-models/injecting-into-commands.md gains the MongoDB section and a table of which provider owns what.

Known boundary, unchanged from #2423

Only Chronicle writes the provider-neutral CommandContextKeys.ResolvedKey, so an application with EF Core or MongoDB and no Chronicle has no key to resolve by, and injection fails as a validation error (HTTP 400) rather than resolving. Every key-resolution rule Arc has — ICanProvideEventSourceId, [Key], EventSourceId-convertible — lives in the Chronicle integration today, so closing this means Arc.Core growing a key convention of its own. That is a deliberate decision I did not take here; happy to file it as a follow-up.

Fixes to #2422

  • SP0041 read paging off a query's parameters, which Arc never fills in. Paging/Sorting travel on QueryContext; ModelBoundQueryPerformer treats any non-DI parameter as a query-string argument and throws MissingArgumentForQuery for a non-nullable one it cannot bind. What actually marks a query paged is an IQueryable return, per ComputeSupportsPaging in both performers — so the canonical paged Arc query was silent while the shape that reports is one Arc rejects. Now driven off the return type. The parameter read is kept for a signature that declares the intent.
  • A route was read only from the symbol it was written on, and only the first of them. ASP.NET Core honors [Route] on a base controller and on an overridden action, and [Route]/the verb attributes are all AllowMultiple. [Route("api/[controller]")] on an abstract base is the common pattern, and IsController excludes abstract types, so that route was served and never mentioned.
  • The read model [Path] report could name a route nothing serves. A [Path] on a query replaces the read model's rather than extending it, so it is now reported only while some query still falls back to it. The spec added in Report what an application says about how it is served #2422 asserted the wrong thing here.
  • Plus Tags.Of returning string[] instead of double-materializing, consistent whitespace handling between the two [Path] reads, and the missing should_compile_the_source_it_analyzed guard on the new controller spec.

Not addressed: tags on a reactor/reducer/projection are still dropped silently, and a read model reached only through IProjectionFor<T> without [ReadModel] never reaches the tag report. Tags.cs's remark promises more than SP0042 delivers — worth a follow-up.

Fixes to #2421

verify-links.sh used one glob per extension, and linkinator aborts on any glob matching nothing — so the .mdx glob would fail a repository whose pages are all .md while every link in it is fine. One {md,mdx} glob now. The scanned-link count was also scraped from output that linkinator colorizes whenever FORCE_COLOR is set, which made a real link failure report as "the globs matched nothing"; the count is read after stripping the escape codes (NO_COLOR alone does not win against FORCE_COLOR).

Not addressed: local lint runs unpinned npx markdownlint-cli2 while CI pins the action, so the two can still drift; and .mdx files are linked-checked but never linted.

Verification

Clean dotnet build -c Release from the repository root: 0 errors, 1 warning — a pre-existing CS0436 in TestApps/Chronicle that reproduces on main unchanged.

All 15 spec projects run locally. Notable: Arc.Core.Specs 1576, Chronicle.Specs 560, EntityFrameworkCore.Specs 300, Screenplay.Specs 1165 (15 new), MongoDB.Specs 148 (9 new). Documentation/verify-markdown.sh passes with 317 links scanned.

One thing to know about the MongoDB specs: resolving looks up a read model's class map, which resolves the serializer for its id member. If the driver caches its own Guid serializer before AddCratisMongoDB registers Cratis's, that registration throws — so the spec context initializes the defaults first, the order an application always has.

Review

Authored by an agent with no human review yet. Please read the ownership design and the ReportHowItIsServed rewrite in particular before merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants