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 =>