diff --git a/Directory.Packages.props b/Directory.Packages.props index 92b5d162a..0bb1c0222 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,13 +41,13 @@ (build/SupervisedTests.cs): class-partitioned parallel workers, per-lane environments, and honest retry reporting. Referenced ONLY by the build project. --> - - - + + + - + diff --git a/docs/guide/command-line.md b/docs/guide/command-line.md index 9223aa02e..74b52ee9d 100644 --- a/docs/guide/command-line.md +++ b/docs/guide/command-line.md @@ -328,6 +328,34 @@ dotnet run -- event-model --json ./docs/orders.json dotnet run -- event-model --json ./out.json --name Orders ``` +### Publishing to a monitor + +`--url` PUTs the assembled descriptor to a monitor instead of writing it to a file, which collapses the +design-time loop to a single command: + +```bash +dotnet watch run -- event-model --url http://localhost:5525 +``` + +The two flags compose. `--url` on its own publishes and writes nothing; add `--json` and you get both, with +the file and the request body byte-for-byte identical: + +```bash +dotnet run -- event-model --url http://localhost:5525 # publish only +dotnet run -- event-model --url http://localhost:5525 --json ./out.json # publish and write +``` + +Wolverine takes **no reference on the monitor** — this is an HTTP PUT to whatever URL you name, so anything +that accepts the descriptor works. A monitor that is down fails with a one-line message and a non-zero exit +rather than a stack trace, because under `dotnet watch` a console you have not started yet is the ordinary +case and must not look like a crash. + +::: tip +Note that the rebuild has to come from `dotnet watch`, not from a `--watch` flag on the command. The command's +process already has your assembly loaded, so an internal loop would re-serialize the same chains forever and +never see an edit — only a fresh process picks up recompiled handlers. +::: + The host is built but **never started**: the handler graph is compiled the same way `wolverine-diagnostics describe-handlers` does it, so no transport is opened, no database is touched, and no runtime compiler is needed — a `TypeLoadMode.Dynamic` application without `WolverineFx.RuntimeCompilation` diff --git a/src/Http/Wolverine.Http/Diagnostics/HttpEventModelSource.cs b/src/Http/Wolverine.Http/Diagnostics/HttpEventModelSource.cs index 5e2686395..304bd1443 100644 --- a/src/Http/Wolverine.Http/Diagnostics/HttpEventModelSource.cs +++ b/src/Http/Wolverine.Http/Diagnostics/HttpEventModelSource.cs @@ -23,6 +23,15 @@ public HttpEventModelSource(WolverineHttpOptions options, WolverineOptions wolve public Uri Subject { get; } = new($"{WolverineEventModelSource.Scheme}://wolverine-http"); + /// + /// GH-4147/GH-4152. Roles here are derived off compiled s, so this source + /// sits on the same rung as Wolverine core's + /// (jasperfx#703), which is what replaced the services.Insert(0, ...) registration hack. + /// Two sources on the same rung union rather than clobber, so this and + /// still both contribute to the one model per service. + /// + public EventModelProvenance Provenance => EventModelProvenance.Derived; + public Task TryCreateAsync(IServiceProvider services, CancellationToken token) { var graph = _options.Endpoints; diff --git a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs index e50fb5c1e..d06b44569 100644 --- a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs @@ -194,11 +194,12 @@ public static IServiceCollection AddWolverineHttp(this IServiceCollection servic // shipped the richer reader yet. services.AddSingleton(); - // GH-3988 — the Wolverine.HTTP-derived Event Model source. Inserted at the front, like Wolverine - // core's, so an overlay registered earlier cannot overwrite a derived role on merge. + // GH-3988 — the Wolverine.HTTP-derived Event Model source. GH-4152: appended rather than inserted + // at the front, now that the source declares EventModelProvenance.Derived and precedence is on the + // ladder instead of registration order. if (services.All(x => x.ImplementationType != typeof(HttpEventModelSource))) { - services.Insert(0, ServiceDescriptor.Singleton()); + services.AddSingleton(); } // Registered unconditionally — harmless when no versioned endpoint uses it. diff --git a/src/Testing/CoreTests/Acceptance/event_model_provenance_ladder_4147.cs b/src/Testing/CoreTests/Acceptance/event_model_provenance_ladder_4147.cs new file mode 100644 index 000000000..22232f288 --- /dev/null +++ b/src/Testing/CoreTests/Acceptance/event_model_provenance_ladder_4147.cs @@ -0,0 +1,120 @@ +using JasperFx.Descriptors; +using JasperFx.Events.EventModeling; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Configuration.EventModeling; +using Xunit; + +namespace CoreTests.Acceptance.EventModel4147; + +// GH-4147 / GH-4152. The acceptance criteria for stamping provenance are precedence claims, so they are +// tested as precedence: "every role the Wolverine source emits is attributed as derived; a +// runtime-observed source can override a derived role; an overlay cannot." +// +// ⚠️ Worth knowing why this file exists at all rather than leaning on the GH-3988 overlay fixture. The +// public overlay API (EventModelSliceBuilder) can only express TriggeredBy / InDomain / +// LinksToSpecification / Hotspot -- annotations, none of which are factual roles. So an overlay +// *cannot* collide with a derived role, and that fixture passes with or without the Derived stamp: it +// never exercises the ladder. Only a custom IEventModelDefinitionSource can claim a role Wolverine also +// claims, which is what these stubs do. +public class event_model_provenance_ladder_4147 +{ + private static async Task assembleWithAsync(params IEventModelDefinitionSource[] rivals) + { + using var host = await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + // Registered BEFORE UseWolverine() so registration order favours the rival. Precedence + // must come off the ladder now, not off the old services.Insert(0, ...) hack. + foreach (var rival in rivals) + { + services.AddSingleton(rival); + } + }) + .UseWolverine(opts => + { + opts.ServiceName = "provenance-4147"; + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(RecordPaymentHandler)); + }).StartAsync(); + + var model = await WolverineEventModelExport.AssembleAsync(host.Services, + token: TestContext.Current.CancellationToken); + + await host.StopAsync(); + return model; + } + + [Fact] + public async Task an_observed_source_overrides_a_derived_role() + { + var model = await assembleWithAsync( + new StubSource(EventModelProvenance.Observed, typeof(ObservedInProduction))); + + // The inversion #4147 asks for, and it is deliberate: what the fleet actually emits beats what + // the code says it should emit. + model.Slices.Single(x => x.Name == nameof(RecordPayment)) + .PublishedMessages.Select(x => x.Name) + .ShouldBe([nameof(ObservedInProduction)]); + } + + [Fact] + public async Task a_declared_source_cannot_override_a_derived_role() + { + var model = await assembleWithAsync( + new StubSource(EventModelProvenance.Declared, typeof(MerelyDeclared))); + + // Registered first, and still loses -- this is exactly what services.Insert(0, ...) used to buy + // and what the Derived stamp buys now. + model.Slices.Single(x => x.Name == nameof(RecordPayment)) + .PublishedMessages.Select(x => x.Name) + .ShouldBe([nameof(PaymentRecorded)]); + } + + [Fact] + public async Task the_derived_role_is_what_wolverine_actually_compiled() + { + var model = await assembleWithAsync(); + + model.Slices.Single(x => x.Name == nameof(RecordPayment)) + .PublishedMessages.Select(x => x.Name) + .ShouldBe([nameof(PaymentRecorded)]); + } + + /// + /// A rival source claiming the same slice's PublishedMessages at a chosen rung. + /// + private sealed class StubSource(EventModelProvenance provenance, Type published) : IEventModelDefinitionSource + { + public Uri Subject { get; } = new("event-model://stub-4147"); + + public EventModelProvenance Provenance => provenance; + + public Task TryCreateAsync(IServiceProvider services, CancellationToken token) + { + var slice = new EventModelSliceDescriptor( + nameof(RecordPayment), null, null, null, null, + Array.Empty(), Array.Empty(), Array.Empty()) + { + PublishedMessages = [TypeDescriptor.For(published)] + }; + + return Task.FromResult( + new EventModelDescriptor("provenance-4147", [slice])); + } + } +} + +public record RecordPayment(string Id); + +public record PaymentRecorded(string Id); + +public record ObservedInProduction(string Id); + +public record MerelyDeclared(string Id); + +public class RecordPaymentHandler +{ + public PaymentRecorded Handle(RecordPayment command) => new(command.Id); +} diff --git a/src/Testing/CoreTests/Acceptance/event_model_publish_url_4146.cs b/src/Testing/CoreTests/Acceptance/event_model_publish_url_4146.cs new file mode 100644 index 000000000..799af3886 --- /dev/null +++ b/src/Testing/CoreTests/Acceptance/event_model_publish_url_4146.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Text; +using JasperFx.Events.EventModeling; +using Shouldly; +using Wolverine.Configuration.EventModeling; +using Xunit; + +namespace CoreTests.Acceptance; + +// GH-4146: `event-model --url ` PUTs the assembled descriptor instead of (or as well as) writing +// the file, so the design-time loop collapses to `dotnet watch run -- event-model --url ...`. +public class event_model_publish_url_4146 +{ + // ---- flag composition (no host, no socket) ---- + + [Fact] + public void neither_flag_still_writes_the_default_file() + { + new EventModelInput().ResolveJsonPath().ShouldBe(EventModelInput.DefaultJsonFile); + } + + [Fact] + public void json_alone_writes_the_named_file() + { + new EventModelInput { JsonFlag = "custom.json" }.ResolveJsonPath().ShouldBe("custom.json"); + } + + [Fact] + public void url_alone_publishes_without_dropping_a_file() + { + // The point of the default moving off "event-model.json": running the watch loop should not + // litter the application directory with a file nobody asked for. + new EventModelInput { UrlFlag = "http://localhost:5525" }.ResolveJsonPath().ShouldBeNull(); + } + + [Fact] + public void json_and_url_compose() + { + new EventModelInput { JsonFlag = "custom.json", UrlFlag = "http://localhost:5525" } + .ResolveJsonPath().ShouldBe("custom.json"); + } + + // ---- the PUT itself, against a real socket ---- + + [Fact] + public async Task publishes_the_same_json_the_file_form_writes() + { + await using var monitor = new StubMonitor(); + + var model = new EventModelDescriptor("PublishMe", []); + var succeeded = await invokePublishAsync(model, monitor.Url); + + succeeded.ShouldBeTrue(); + monitor.Method.ShouldBe("PUT"); + monitor.ContentType.ShouldStartWith("application/json"); + + // Byte-for-byte the file form's payload, so a monitor cannot tell the two apart. + monitor.Body.ShouldBe(WolverineEventModelExport.ToJson(model)); + + // ...and it round-trips back through the descriptor. + WolverineEventModelExport.FromJson(monitor.Body!)!.Name.ShouldBe("PublishMe"); + } + + [Fact] + public async Task a_monitor_that_rejects_the_model_fails_rather_than_reporting_success() + { + await using var monitor = new StubMonitor(HttpStatusCode.BadRequest, "not today"); + + var succeeded = await invokePublishAsync(new EventModelDescriptor("Rejected", []), monitor.Url); + + succeeded.ShouldBeFalse(); + } + + [Fact] + public async Task a_monitor_that_is_down_fails_with_a_message_not_a_stack_trace() + { + // Nothing is listening here. Under `dotnet watch` a monitor that has not been started yet is the + // ordinary case, so this has to be a sentence and a non-zero exit -- never an unhandled exception. + var output = new StringWriter(); + var original = Console.Out; + Console.SetOut(output); + + bool succeeded; + try + { + succeeded = await invokePublishAsync(new EventModelDescriptor("Nobody", []), + new Uri($"http://127.0.0.1:{unusedPort()}")); + } + finally + { + Console.SetOut(original); + } + + succeeded.ShouldBeFalse(); + output.ToString().ShouldContain("Could not reach the monitor"); + } + + private static Task invokePublishAsync(EventModelDescriptor model, Uri monitor) + { + var method = typeof(EventModelCommand) + .GetMethod("publishAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + + return (Task)method.Invoke(null, [model, monitor, "the Event Model"])!; + } + + private static int unusedPort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// Minimal HttpListener standing in for a Bobcat/CritterWatch console. + private sealed class StubMonitor : IAsyncDisposable + { + private readonly HttpListener _listener = new(); + private readonly Task _serving; + + public StubMonitor(HttpStatusCode status = HttpStatusCode.NoContent, string? responseBody = null) + { + var port = unusedPort(); + Url = new Uri($"http://127.0.0.1:{port}/event-model"); + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + _listener.Start(); + + _serving = Task.Run(async () => + { + var context = await _listener.GetContextAsync(); + Method = context.Request.HttpMethod; + ContentType = context.Request.ContentType; + using (var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8)) + { + Body = await reader.ReadToEndAsync(); + } + + context.Response.StatusCode = (int)status; + if (responseBody != null) + { + var bytes = Encoding.UTF8.GetBytes(responseBody); + await context.Response.OutputStream.WriteAsync(bytes); + } + + context.Response.Close(); + }); + } + + public Uri Url { get; } + public string? Method { get; private set; } + public string? ContentType { get; private set; } + public string? Body { get; private set; } + + public async ValueTask DisposeAsync() + { + try + { + await _serving.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch + { + // the negative tests never send a request; nothing to drain + } + + _listener.Close(); + } + } +} diff --git a/src/Testing/CoreTests/Acceptance/event_model_roles_3988.cs b/src/Testing/CoreTests/Acceptance/event_model_roles_3988.cs index 42e63fe21..0d91250e7 100644 --- a/src/Testing/CoreTests/Acceptance/event_model_roles_3988.cs +++ b/src/Testing/CoreTests/Acceptance/event_model_roles_3988.cs @@ -177,7 +177,10 @@ public async ValueTask InitializeAsync() .ConfigureServices(services => { // Registered BEFORE UseWolverine() on purpose: the derived source must still win on - // merge, so the overlay may only fill gaps (the trigger label) — never a derived role + // merge, so the overlay may only fill gaps (the trigger label) — never a derived role. + // GH-4152: this is now the regression guard for the provenance ladder specifically. The + // overlay is registered first and is no longer beaten by registration order, so if the + // Derived stamp ever came off, this fixture would start losing derived roles to it. services.AddEventModel("Overlay", model => { model.InDomain("Sales"); @@ -199,12 +202,26 @@ public async ValueTask DisposeAsync() _host.Dispose(); } + // GH-4152: this used to assert the Wolverine source was registered FIRST, because until JasperFx 2.56 + // that ordering was the only thing making derived roles beat an overlay's -- hence the + // services.Insert(0, ...) in UseWolverine(). Precedence now rides the provenance ladder, so the + // registration order is no longer meaningful and asserting it would be pinning an implementation + // detail we deliberately removed. What has to hold is the rung. [Fact] - public void the_wolverine_source_is_registered_first() + public void the_wolverine_source_claims_the_derived_rung() { var sources = _host.Services.GetServices().ToArray(); - sources.First().ShouldBeOfType(); sources.Length.ShouldBe(2); + + // Through the interface on purpose: Provenance is a default interface member, so reading it off + // the concrete type would only compile when the override exists and would assert nothing. + IEventModelDefinitionSource wolverine = sources.OfType().Single(); + wolverine.Provenance.ShouldBe(EventModelProvenance.Derived); + + // The overlay registered in InitializeAsync is unstamped, so it stays on the bottom rung -- which + // is what lets it fill gaps without overwriting anything derived, whatever order it registered in. + var overlay = sources.Single(x => x is not WolverineEventModelSource); + overlay.Provenance.ShouldBe(EventModelProvenance.Declared); } [Fact] diff --git a/src/Wolverine/Configuration/EventModeling/EventModelCommand.cs b/src/Wolverine/Configuration/EventModeling/EventModelCommand.cs index cab8fc3b3..0980af291 100644 --- a/src/Wolverine/Configuration/EventModeling/EventModelCommand.cs +++ b/src/Wolverine/Configuration/EventModeling/EventModelCommand.cs @@ -72,19 +72,56 @@ public static Task WriteAsync(EventModelDescriptor model, Stream stream, Cancell public class EventModelInput : NetCoreInput { - [Description("Path of the JSON file to write the Event Model to")] + /// + /// GH-4146: defaults to null rather than to event-model.json so that --url on its own + /// publishes without also dropping a file next to the application. With neither flag the command + /// still writes , exactly as it always has. + /// + [Description("Path of the JSON file to write the Event Model to; defaults to event-model.json unless --url is given")] [FlagAlias("json", 'j')] - public string JsonFlag { get; set; } = "event-model.json"; + public string? JsonFlag { get; set; } [Description("Optional name for the assembled model; defaults to the Wolverine service name")] public string? NameFlag { get; set; } + + /// + /// GH-4146: PUT the assembled descriptor to a monitor instead of (or as well as) writing it to a + /// file, so the design-time loop is one command: dotnet watch run -- event-model --url .... + /// + [Description("URL of a monitor to PUT the assembled Event Model to; composes with --json")] + [FlagAlias("url", 'u')] + public string? UrlFlag { get; set; } + + /// Where the Event Model goes when neither --json nor --url is supplied. + public const string DefaultJsonFile = "event-model.json"; + + /// + /// The file to write, or null when --url was given without --json and the descriptor + /// should only be published. + /// + internal string? ResolveJsonPath() + { + if (JsonFlag.IsNotEmpty()) + { + return JsonFlag; + } + + return UrlFlag.IsEmpty() ? DefaultJsonFile : null; + } } /// -/// dotnet run -- event-model [--json <path>]: write the host's merged Event Model as JSON -/// without a running fleet (GH-3990). The host is built but never started: the handler graph is -/// compiled by resolving the code file collections — the wolverine-diagnostics describe-handlers -/// trick — so no transport is opened, no database is touched, and no runtime compiler is needed. +/// dotnet run -- event-model [--json <path>] [--url <monitor>]: write the host's merged +/// Event Model as JSON, publish it to a monitor, or both — without a running fleet (GH-3990). The host is +/// built but never started: the handler graph is compiled by resolving the code file collections — +/// the wolverine-diagnostics describe-handlers trick — so no transport is opened, no database is +/// touched, and no runtime compiler is needed. +/// +/// GH-4146: with --url the whole design-time loop becomes +/// dotnet watch run -- event-model --url http://localhost:5525. Note that the rebuild has to come +/// from dotnet watch and not from a --watch flag here: this process already has the +/// assembly loaded, so an internal loop would re-serialise the same chains forever and never see an +/// edit. Only a fresh process picks up recompiled handlers. /// [Description("Write the application's Event Model — the roles every handler, HTTP and gRPC chain derives about itself, plus any registered overlay — as JSON, without a running fleet", Name = "event-model")] @@ -96,14 +133,28 @@ public EventModelCommand() Usage("Write the Event Model to the designated file").Arguments(); } + /// + /// GH-4146: how long to wait on the monitor before giving up. The point of --url is a fast + /// design-time loop, so a monitor that is not answering has to fail quickly rather than stall + /// dotnet watch. + /// + internal static TimeSpan PublishTimeout { get; set; } = TimeSpan.FromSeconds(10); + public override async Task Execute(EventModelInput input) { - if (input.JsonFlag.IsEmpty()) + Uri? monitor = null; + if (input.UrlFlag.IsNotEmpty()) { - Console.WriteLine("No file name supplied."); - return false; + if (!Uri.TryCreate(input.UrlFlag, UriKind.Absolute, out monitor) || + (monitor.Scheme != Uri.UriSchemeHttp && monitor.Scheme != Uri.UriSchemeHttps)) + { + Console.WriteLine($"'{input.UrlFlag}' is not a valid absolute http:// or https:// URL."); + return false; + } } + var jsonPath = input.ResolveJsonPath(); + // Set BEFORE the host is built, exactly as the codegen and wolverine-diagnostics commands do, // so Wolverine bootstraps in lightweight mode — no handler registry consumption, no // transport or durability side effects. @@ -121,22 +172,31 @@ public override async Task Execute(EventModelInput input) _ = host.Services.GetServices().ToArray(); var model = await WolverineEventModelExport.AssembleAsync(host.Services, input.NameFlag); + var summary = + $"the Event Model '{model.Name}' ({model.Slices.Count} slices, {model.Aggregates.Count} aggregates)"; - var path = input.JsonFlag.ToFullPath(); - if (Path.GetDirectoryName(path) is { Length: > 0 } directory) + if (jsonPath is not null) { - Directory.CreateDirectory(directory); + var path = jsonPath.ToFullPath(); + if (Path.GetDirectoryName(path) is { Length: > 0 } directory) + { + Directory.CreateDirectory(directory); + } + + await using (var stream = new FileStream(path, FileMode.Create)) + { + await WolverineEventModelExport.WriteAsync(model, stream); + await stream.FlushAsync(); + } + + Console.WriteLine($"Wrote {summary} to {path}"); } - await using (var stream = new FileStream(path, FileMode.Create)) + if (monitor is not null) { - await WolverineEventModelExport.WriteAsync(model, stream); - await stream.FlushAsync(); + return await publishAsync(model, monitor, summary); } - Console.WriteLine( - $"Wrote the Event Model '{model.Name}' ({model.Slices.Count} slices, {model.Aggregates.Count} aggregates) to {path}"); - return true; } finally @@ -144,4 +204,52 @@ public override async Task Execute(EventModelInput input) DynamicCodeBuilder.WithinCodegenCommand = false; } } + + /// + /// GH-4146. PUT the descriptor to the monitor as the same JSON the file form writes. Wolverine takes + /// no reference on the monitor — this is an HTTP PUT to whatever URL the caller names, the + /// wire-not-reference posture CritterWatch already takes — so any endpoint that accepts the + /// descriptor works, and nothing here knows what is on the other end. + /// + /// Every failure is reported as a sentence and a non-zero exit rather than a stack trace: this + /// runs inside dotnet watch, where a monitor that is simply not running yet is the ordinary + /// case and must not look like a crash. + /// + private static async Task publishAsync(EventModelDescriptor model, Uri monitor, string summary) + { + using var client = new HttpClient { Timeout = PublishTimeout }; + + try + { + var json = WolverineEventModelExport.ToJson(model); + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + using var response = await client.PutAsync(monitor, content); + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + Console.WriteLine( + $"The monitor at {monitor} rejected {summary}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}."); + if (body.IsNotEmpty()) + { + Console.WriteLine(body.Trim()); + } + + return false; + } + + Console.WriteLine($"Published {summary} to {monitor}"); + return true; + } + catch (TaskCanceledException) + { + Console.WriteLine($"The monitor at {monitor} did not respond within {PublishTimeout.TotalSeconds:0.#} seconds."); + return false; + } + catch (HttpRequestException e) + { + Console.WriteLine($"Could not reach the monitor at {monitor}: {e.Message}"); + return false; + } + } } diff --git a/src/Wolverine/Configuration/EventModeling/WolverineEventModelSource.cs b/src/Wolverine/Configuration/EventModeling/WolverineEventModelSource.cs index 07ce8330c..0f5a0982b 100644 --- a/src/Wolverine/Configuration/EventModeling/WolverineEventModelSource.cs +++ b/src/Wolverine/Configuration/EventModeling/WolverineEventModelSource.cs @@ -9,9 +9,7 @@ namespace Wolverine.Configuration.EventModeling; /// /// The Wolverine-derived (GH-3988): one slice per message /// handler chain, with the roles derives off the chain, plus the gRPC -/// trigger for any message an RPC forwards to the bus. Registered by UseWolverine() ahead of -/// every other source so that lets the derived roles win -/// over an overlay's names. +/// trigger for any message an RPC forwards to the bus. /// /// /// HTTP chains are described by Wolverine.Http's sibling source — the HTTP graph is not known to @@ -25,6 +23,25 @@ public sealed class WolverineEventModelSource : IEventModelDefinitionSource public Uri Subject { get; } = new($"{Scheme}://wolverine"); + /// + /// GH-4147/GH-4152. Every role this source claims is read off a compiled handler chain, so it sits + /// on the rung rather than the + /// default (jasperfx#703). + /// + /// This is what makes derived roles beat an overlay's. Until JasperFx 2.56 the mechanism was + /// registration order — UseWolverine() did services.Insert(0, ...) purely so this + /// source merged first — which was load-bearing behaviour that nothing in the registration + /// explained. Precedence is now on the ladder, so the insert is gone and the ordering no longer + /// matters. + /// + /// Note the deliberate inversion: a source that observes a running system outranks + /// this one. That is the point of the ladder, not a regression — production truth beats what the + /// code says it should do. Precedence is also per claimed role, so this does not start + /// overwriting an overlay's slice names, domains or specification links; nothing else claims the + /// factual roles this source fills in. + /// + public EventModelProvenance Provenance => EventModelProvenance.Derived; + public Task TryCreateAsync(IServiceProvider services, CancellationToken token) { // WolverineOptions rather than IWolverineRuntime on purpose: the export command (GH-3990) diff --git a/src/Wolverine/HostBuilderExtensions.cs b/src/Wolverine/HostBuilderExtensions.cs index 59d72ed3a..fbb2bc59a 100644 --- a/src/Wolverine/HostBuilderExtensions.cs +++ b/src/Wolverine/HostBuilderExtensions.cs @@ -207,13 +207,13 @@ internal static IServiceCollection AddWolverine(this IServiceCollection services services.AddSingleton(); - // GH-3988: the Wolverine-derived Event Model source. Inserted at the FRONT of the collection - // rather than appended, because JasperFx's EventModelDiscovery folds sources in registration - // order with the earlier winning on every scalar — and a derived role must never be overwritten - // by an overlay the application registered before UseWolverine(). + // GH-3988: the Wolverine-derived Event Model source. GH-4152: a plain append, because the source + // now declares EventModelProvenance.Derived and the merge resolves on that ladder rather than on + // registration order. This used to be services.Insert(0, ...) solely so a derived role could not be + // overwritten by an overlay registered before UseWolverine(). if (services.All(x => x.ImplementationType != typeof(WolverineEventModelSource))) { - services.Insert(0, ServiceDescriptor.Singleton()); + services.AddSingleton(); } services.AddSingleton(sp =>