Inject read models from any provider, and ship the open work as one release - #2425
Inject read models from any provider, and ship the open work as one release#2425woksin wants to merge 28 commits into
Conversation
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
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
What this supersedesThis branch is Why the Chronicle update needed a fix, and why nobody saw it#2424 on its own does not build. Chronicle 16.10.0 adds It went unnoticed because Design changes to #2423Two providers can be able to load the same read model, and A resolver now declares a
Known boundary, unchanged from #2423Only Chronicle writes the provider-neutral Fixes to #2422
Not addressed: tags on a reactor/reducer/projection are still dropped silently, and a read model reached only through Fixes to #2421
Not addressed: local lint runs unpinned VerificationClean 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). 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 ReviewAuthored by an agent with no human review yet. Please read the ownership design and the |
Summary
The five open pull requests are merged here so they ship as one release rather than five.
Added
Handle(),Provide(), andCommandValidator<>, with nothing to wire up — an entity carried by aReadOnlyDbContextresolves by its primary key, a[ReadModel]held in MongoDB by its document_id. Nullable and non-nullable resolution behave as for a Chronicle-backed read model (Support injecting read models from non-Chronicle providers into the command pipeline #2303)ICanResolveReadModelForCommand, for making the read models of another store injectable into a command the same way. A provider states whether it owns the read models it reports or can merely load them, so which one resolves a read model does not depend on registration order (Support injecting read models from non-Chronicle providers into the command pipeline #2303)SP0041reports what an application says about how it is served and a Screenplay has no counterpart for: a query the host pages and sorts, and every route template declared with[Path],[Route]or an HTTP verb, including inherited ones (Screenplay generation: query paging, sorting, routes and read model tags are dropped silently #2415)SP0042reports the tags a read model carries, which the document has nowhere to hold while an event's tags are printed (Screenplay generation: query paging, sorting, routes and read model tags are dropped silently #2415)ComplianceMetadataActionFailedreplaces the bareInvalidOperationExceptionwhen applying or releasing compliance metadata fails. On a failed release it names the property, the subject the value was released under, and explains that the value was encrypted under a different subject, pointing at CHR0038 — rather than surfacing only the underlying OpenSSLoaep decoding error.[Replay]on a reactor handler method, marking it as the one to run while the observer is replaying. Only the marked handler runs during a replay; an event type without one behaves exactly as before. (Support separate event handler methods for events during replay Chronicle#845)IEventStore.IdentitieswithRename(subject, name), exposing the identity renaming the kernel already supported to the .NET client. (Add the capability of changing name of an identity Chronicle#1684)[JsonSchemaType(typeof(T))]on a type, overriding how it is represented in the JSON schema generated for events and read models. Compliance metadata and nullability are preserved. (Add support for overriding the type a type should be represented as in the JsonSchema when used in an Event or Read Model Chronicle#1470)AddCratisChronicleMongoDB()for Aspire, provisioning a MongoDB container that initiates itself as a single-node replica set and hands back adirectConnectionconnection string. (Aspire: slim-image WithMongoDB silently requires a MongoDB replica set + directConnection — document and/or provide a provisioning helper Chronicle#3400)IReactorMiddleware.BeforeInvokeandAfterInvokeoverloads that receive theReactorIdof the reactor observing the event. Chronicle calls these; they default to the existing overloads, so a middleware written against the previous interface keeps working unchanged. (IObserverMiddleware should have which observer it is running for Chronicle#1009)CompliancePropertyNotFoundInSchema, thrown when a property being applied or released has no counterpart in the schema. The message names the property path, the compliance subject, and the properties the schema does declare. (Improve error messages from JSON Compliance Manager when releasing events Chronicle#690)cratis/chronicleDocker Hub repository covering the published tag variants, ports, quick start and the environment variables that configure the server. (Add an overview on Docker hub for Chronicle images Chronicle#1277)Changed
Cratis.Chronicle,Cratis.Chronicle.AspNetCoreandCratis.Chronicle.Testingare updated from16.9.1to16.10.0, picking up 16.9.2, 16.9.3, 16.9.4 and 16.10.0CHR0038now also reports the fluentbuilder.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.Fixed
X-Tenant-IDas the tenant header; the header-override samples now use a clearly-custom placeholder so they no longer contradict the actual defaultx-cratis-tenant-id(Header tenant resolution silently yields no tenant: IHttpRequestContextAccessor is not populated for the whole request #2351)CHR0038no longer reports a fluent join when AutoMap cannot carry the value across —[NoAutoMap]on the read model, and[NoAutoMap]on the target property of a positional record, were both still reported. Since the rule is an error, each was a build break on correct code./arc/backend/chronicle/aggregates/aggregate-root.md, which no longer exists — the Arc page isdefining-an-aggregate-root.md. This was breaking the Documentation site's internal-links check.NullReferenceException, losing the status and progress the job state was written to record. (for_JobStateSerializer specs run against a class map production never uses Chronicle#3541)Sequence contains no matching element. (Improve error messages from JSON Compliance Manager when releasing events Chronicle#690)