Skip to content

Descriptors and the Wire ​

Every source of an Event Model — Wolverine's chains, the Bobcat generator, a source generator, your overlay — writes into the same two records, and every viewer reads from them. This page is the shape.

EventModelSliceDescriptor ​

One slice. The positional constructor is the original 2.x shape and is kept source- and binary-compatible; everything added since is an init property with a safe default, so older payloads and precompiled callers keep working.

"Derived" below is where a role normally comes from once code exists. Every one of them can also be declared for design-first work; a declaration sits on the lowest rung, so the derived claim wins when both are present.

SlotFilled byHolds
NameBothDisplay name; also the merge key across sources
PatternDerivedCommand, View, Automation, Translation or Query
TriggerKindDerivedHttp, Grpc, MessageHandler, JobScheduler, Human, External — on a merged slice, how the code receives the trigger
TriggeredByMergeWho triggers the slice when that differs from how — Human beside a derived Http, Grpc or MessageHandler. See Who and how
TriggerOriginDerivedHTTP verb + route (and the route name, HttpRouteName, when there is one), gRPC service + method, or a label
TriggerTypeDerivedCLR type of the trigger, e.g. an inbound request DTO
TriggerLabelOverlay"Agent clicks Close"
CommandTypeDerivedThe inbound message type
HandlerTypeDerivedThe user's handler or endpoint class — distinct from the aggregates, and never generated code. See Handler identity
HandlerMethodDerivedThe name of the user's handler or endpoint method on HandlerType
AdditionalEntryPointsMergeThe ways into the slice beyond the one TriggerKind and HandlerType name. See Two entry points to one command
AggregateTypesDerivedProjected write models the handler decides against
EmittedEventsDerivedEvents the slice writes, in declaration order
PublishedMessagesDerivedNon-event messages — cascaded commands, integration messages
ProjectionTypesDerivedProjections consuming the slice's events
ReadModelTypesDerivedRead models the slice produces
ConsumedEventsDerivedEvents the slice's projection / read model / automation applies
ReadsFromDerivedRead models the slice reads before deciding, or what a Query slice reads and returns
ExternalSystemsDerivedSystems on either end of a translation
SpecificationsDerived (mostly)Bound specs by {Feature}/{Scenario} plus resolved types
HotspotsBothPending specs (derived) and prose (overlay)
DomainDeclaredBounded context -- in a modular monolith, the module
ChapterDeclaredA named span of slices — the navigation unit above Domain
StartsStreamEitherThe aggregate whose stream the slice starts, as against appends to
AggregateDeclarationDeclaredWhy the slice has its aggregate: Default (from ForAggregate), Explicit, None or DeciderModel
DeciderModelDeclaredThe Dynamic Consistency Boundary decider model the slice decides through
OriginAny sourceWhich source contributed the slice — a file path, a suite or assembly name, a store URI. Stamp it and a source disagreement names a party rather than a rung

What a slice reads ​

The first three roles above record what a slice produces. ConsumedEvents and ReadsFrom are the other direction, and without them two ordinary arrows could not be drawn at all:

  • State View — event → read model, across slices. Nothing could say "this projection applies AccountOpened", so a View slice existed on a canvas only when somebody declared one.
  • Automation input — Event → Read Model (todo list) → ⚙ Command. ReadModelTypes meant "reads from or produces", so nothing linked a read model to a processor.

Each ConsumedEvents entry becomes an Event element in the consuming slice's own event-stream lane — the "repeat the sticky where it is consumed" convention — and each ReadsFrom entry a ReadModel element pointing at the processor. An event a slice both emits and consumes is one element, and the same for a read model it both reads and produces.

TriggerOrigin merges as one value, except for HttpRouteName: a known route name beats an unknown one even when the other source's origin wins, and two different known names for the same origin leave a SourceDisagreement on TriggerOrigin.

Who triggers a slice, and how the code receives it ​

Human answers who triggers a slice. Every other TriggerKind answers how the code receives it. A board or a declaration usually says Human; Wolverine derives MessageHandler, Http or Grpc. Those two claims don't disagree, so the merge keeps both:

  • TriggerKind holds the derived how, with its TriggerOrigin (verb + route, or gRPC service + method) and the slice's HandlerType / HandlerMethod.
  • TriggeredBy holds the declared Human.
  • No hotspot is raised. ProvenanceFor(EventModelRole.TriggerKind) reads Derived and ProvenanceFor(EventModelRole.TriggeredBy) reads Declared.

A slice that hasn't met any code keeps Human on TriggerKind, and TriggeredBy stays null. IsTriggeredByHuman() answers the question either way.

DeclaredDerivedResult
HumanHttpCompatible: TriggerKind = Http, TriggeredBy = Human
HumanGrpcCompatible: TriggerKind = Grpc, TriggeredBy = Human
HumanMessageHandlerCompatible: TriggerKind = MessageHandler, TriggeredBy = Human
HumanHumanSame claim; nothing to merge
HumanJobSchedulerSourceDisagreement on TriggerKind: a schedule is not a person
HumanExternalSourceDisagreement on TriggerKind: another system is not a person
any other pair of different kindsSourceDisagreement on TriggerKind, as before

The rule is symmetric and doesn't depend on the rungs. TriggerKindCompatibility.IsCompatibleWith and CanBeHumanTriggered are the table in code.

Two entry points to one command ​

Wolverine.HTTP names an endpoint's slice for its request type, the same name a message handler for that command gets, so the two fold into one slice. Both claims are true, so the fold raises no hotspot on the roles that name an entry point. Whichever source came first, the merged slice keeps the HTTP side:

  • TriggerKind is Http, beside the route on TriggerOrigin.
  • HandlerType, HandlerMethod and Origin are the endpoint's.
  • AdditionalEntryPoints holds the message handler: its Kind, HandlerType, HandlerMethod and Origin.

AdditionalEntryPoints is for a console that wants to navigate to the other handler, and a spec manifest's Namespace matches it as well as HandlerType. The slice's Elements still draw one handler.

This is the fold of two Derived sources claiming Http and MessageHandler, and nothing else is:

SourcesResult
Two Derived sources, Http and MessageHandlerThe fold above
The same pair from one source (the same Origin)SourceDisagreement on TriggerKind, or a SliceCollision if the handlers differ
A declared Http met by a derived MessageHandler, or the reverseSourceDisagreement: the board wants an entry point the code doesn't have
An observed kind met by a derived oneSourceDisagreement: production outranks the code
Two unstamped sourcesSourceDisagreement, as before
Two Derived sources with any other kindsSourceDisagreement on TriggerKind, HandlerType and Origin, as before

Roles outside the entry point still merge as usual, so an endpoint and a handler in two different Domains disagree about Domain. Grpc isn't part of the pair: Wolverine stamps an RPC's trigger onto the message handler's slice inside one source, so the two never meet in a merge.

Merges are pairwise, so a declared Http that meets the message handler before the endpoint records MessageHandler beating it on the way, and that hotspot stays even though the slice ends on Http.

Handler identity ​

HandlerType and HandlerMethod name the code the user wrote, so a viewer like CritterWatch can navigate straight to it. They never name generated code. Wolverine wraps every handler in a generated MessageHandler subclass and every HTTP endpoint in a generated HttpHandler subclass, and neither of those belongs on a descriptor:

SourceHandlerTypeHandlerMethod
A message handlerchain.Handlers[0].HandlerTypechain.Handlers[0].Method.Name
An HTTP endpointHttpChain.Method.HandlerTypeHttpChain.Method.Method.Name

HandlerRelationshipDescriptor has the same pair (HandlerType plus an optional HandlerMethod), and ToSliceDescriptor() carries both onto the slice.

HandlerMethod merges as part of the handler. It goes with whichever HandlerType wins, and it's never taken from a different type. If both sides name the same type, a known method fills in an unknown one. Two different methods on the same type leave a SourceDisagreement on HandlerType, e.g. Derived claims OrderHandler.Handle; Declared claims OrderHandler.Consume, unless they're an endpoint and a message handler on one class.

Chapter merges like Domain: first non-null wins, and a genuine disagreement between two sources becomes a SourceDisagreement hotspot rather than a silent drop.

This is what a derived source produces for CloseIncident:

cs
// This is what a source builds -- Wolverine reading its own HTTP chain for
// CloseIncidentEndpoint. You never hand-write this; it is here so you can see
// exactly which slots the code fills in.
var derived = new EventModelSliceDescriptor(
    "CloseIncident",
    TriggerLabel: null,
    TriggerType: null,
    CommandType: TypeDescriptor.For(typeof(CloseIncident)),
    HandlerType: TypeDescriptor.For(typeof(CloseIncidentEndpoint)),
    EmittedEvents: [TypeDescriptor.For(typeof(IncidentClosed))],
    ProjectionTypes: [],
    ReadModelTypes: [TypeDescriptor.For(typeof(Incident))])
{
    Pattern = SlicePattern.Command,
    TriggerKind = TriggerKind.Http,
    TriggerOrigin = new PublisherOrigin
    {
        HttpMethod = "POST",
        HttpRoute = "/api/incidents/close/{id}",
        Label = "POST /api/incidents/close/{id}"
    },
    AggregateTypes = [TypeDescriptor.For(typeof(Incident))],
    PublishedMessages = [TypeDescriptor.For(typeof(ArchiveIncident))],
    Provenance = EventModelProvenance.Derived
};

snippet source | anchor

EventModelDescriptor ​

The whole model: its Slices, the Aggregates those slices reference by type (each with its kind, applied events and identity type), model-level Hotspots, the cross-slice Links, and DomainAssignments -- the declared policies putting handlers and endpoints into domains (see Modules are Domains).

A type declared by name -- a TypeDescriptor with no namespace and no assembly -- is the same type as the real one once it exists. EventModelSliceDescriptor.SameType() is that rule: full names when both sides are real types, simple names when either side is a declaration. The merge, the links, the rendered elements and the model's aggregates all use it, so a declaration and its real type are one element, never two.

An aggregate's identity type ​

AggregateDescriptor.IdentityType is the type of the aggregate's stream id: IncidentId, Guid or string. ProjectionEventModelSource derives it from the aggregate's single-stream projection, so a running system needs no declaration; Aggregate<T>().IdentifiedBy<TId>() declares it before the code exists.

AggregateDescriptor.Provenance records the rung of the source that described the aggregate, and WithProvenance stamps it along with the slices. The merge uses it for the identity type only: the higher rung wins, a tie keeps the first, and two different identity types leave a model-level SourceDisagreement hotspot with Role = AggregateIdentity:

⚠ AggregateIdentity of Incident: Derived claims Guid; Declared claims IncidentId

Which aggregate entry survives a merge hasn't changed: the first one, unless a real type replaces a declaration.

The rendering contract ​

Elements, Edges and Links are computed from the typed roles on every read. They are not stored and cannot disagree with the roles underneath them; a deserializer simply ignores whatever arrived and recomputes.

Each element carries a deterministic id — {slice}/{kind}/{type full name or label} — a kind, a lane, a label, and the CLR type identity when it has one. Edges reference elements by that id.

PatternViolations ​

PatternViolations is computed the same way: a PatternViolation hotspot for each role the slice's pattern rules out. Today that's a Query slice carrying EmittedEvents, StartsStream or PublishedMessages; Role on the hotspot names which. ConsumedEvents on a query is fine, since reading an aggregate by live aggregation consumes its events without changing anything.

It's evaluated over the merged roles, so a declared Query that the code turns out to make a Command is a SourceDisagreement on Pattern, not a violation. A violation isn't stored in Hotspots; it renders as a Hotspot element in the slice's wireframe lane and goes away when the slice is fixed.

Elements and Edges are slice-local: element ids are scoped to a slice, so the same event type in two slices is two ids, and neither says how the slices relate. EventModelDescriptor.Links is that missing axis — one entry per cause→effect relationship, with both ends given as element ids so a viewer that has laid out the elements can draw a link with no further resolution.

KindMeans
EventTriggersA emits E; B's CommandType or TriggerType is E
MessageTriggersA publishes M; B handles M — the cascading-message arrow
EventConsumedA emits E; B's ConsumedEvents contains E — the State View arrow
ReadModelReadA produces R; B's ReadsFrom contains R — the Automation input, and the View → Query arrow

Three rules are worth knowing:

  • A slice never links to itself. A slice that emits an event and handles it already says so through its own Edges.
  • Type identity follows the merge rule. Key on FullName when both sides carry a non-empty AssemblyName, else on the short Name — an empty assembly name marks a declaration whose FullName is a synthesized guess, so a spec-declared slice still links to the code that implements it.
  • Order is deterministic — by FromSlice declaration order, then ToSlice, then kind, then type — so a serialized document is byte-stable and a diff of two exports shows only what changed.

The join is a public pure function, EventModelLinks.Compute(model), so a consumer that needs the relationship for something other than rendering (Wolverine's Automation reclassification, an MCP "what consumes OrderPlaced?" tool) uses the same rule rather than a second copy of it. Because links are computed over the merged slices, a relationship no single source could see — one source knows the event is emitted, another knows the second slice handles it — appears once the model is assembled.

cs
// Elements and Edges are computed from the typed roles on every read, so a viewer
// draws straight from the descriptor with no second transform
foreach (var element in slice.Elements)
{
    Console.WriteLine($"{element.Lane,-12} {element.Kind,-15} {element.Label} " +
                      $"({EventModelPalette.ColorFor(element.Kind)})");
}

foreach (var edge in slice.Edges)
{
    Console.WriteLine($"{edge.FromId} -> {edge.ToId}");
}

snippet source | anchor

EventModelPalette.ColorFor is the shared reference so two viewers of one descriptor agree on what a colour means:

KindLaneColour
TriggerWireframe#FFFFFF white
ExternalSystemWireframe#F8BBD0 pink
HotspotWireframe#E91E63 magenta
CommandCommand#5B9BD5 blue
HandlerCommand#5B9BD5 blue, outlined
AggregateCommand#FFF2A8 pale yellow
EventEventStream#F5A623 orange
MessageEventStream#5B9BD5 blue, dashed
ProjectionReadModel#7ED321 green, outlined
ReadModelReadModel#7ED321 green

Discovery and assembly ​

EventModelDiscovery walks every registered IEventModelDefinitionSource, asks each for its descriptor (skipping any that return null), and folds the results into one descriptor per model name:

cs
// Ask every registered source — Wolverine's chains, the Bobcat generator, your
// overlays — for its view, then fold them into one descriptor per model name
var models = await EventModelDiscovery.AssembleAsync(services);

var helpdesk = models.Single(x => x.Name == "Helpdesk");

foreach (var slice in helpdesk.Slices)
{
    Console.WriteLine($"{slice.Domain}/{slice.Name}: {slice.Pattern}");

    foreach (var hotspot in slice.Hotspots)
    {
        Console.WriteLine($"  ⚠ {hotspot.Origin}: {hotspot.Text}");
    }
}

// Questions that belong to the model rather than to one slice
foreach (var hotspot in helpdesk.Hotspots)
{
    Console.WriteLine($"⚠ {hotspot.Text}");
}

snippet source | anchor

Provenance decides the merge ​

Four producers feed one descriptor — Gherkin specs, the C# overlay and code-first specs, Wolverine's chains, and runtime observation from CritterWatch — so something has to arbitrate when two of them describe the same slice. That something is a ladder of authority, EventModelProvenance:

RungWhoBeats
DeclaredA Gherkin spec, a code-first spec, the EventModelDefinition overlay—
SpecifiedLinks read off the specifications by EventModelSpecifications.LinkDeclared
DerivedWolverine's handler / HTTP / gRPC chains, the source generatorSpecified, Declared
ObservedCritterWatch watching a running systemDerived, Specified, Declared

Compare rungs by rank

Specified was added after the others, so its wire value is 3 although it ranks second. Compare rungs with Rank() / Outranks(), never with > on the enum.

Production beats what the code implies, and the code beats what somebody wrote down. A source declares its rung once, on IEventModelDefinitionSource.Provenance; EventModelDiscovery.DiscoverAsync stamps it onto every slice the source returns.

This inverts the pre-2.56 ordering

Registration order used to be the mechanism — WolverineEventModelSource was registered at index 0 specifically so derived roles would beat overlays. It is now only a tie-breaker between sources on the same rung. Provenance defaults to Declared on every source, so an application whose sources have not been stamped yet gets exactly the merge it got before.

Precedence is per claimed role, not wholesale. A role is claimed when the slice carries a value for it — a non-null scalar or a non-empty list — and a source that does not claim a role never overrides one that does, whatever rung it sits on. This is why slice names, domains, trigger labels and specification links keep coming from declarations and keep winning by default: production has no opinion about what a slice is called. The ladder only decides factual roles — which events are emitted, which aggregates are touched, which read models are produced.

Concretely:

  • Scalars go to the highest rung that claims them; a tie keeps the first value.
  • Lists go to the highest rung that claims them outright — a higher rung replaces rather than unions, because unioning derived {A, C} with observed {A, B} invents a slice emitting three events that nobody claimed. Lists claimed at the same rung union in order and deduplicate by identity: types by full name, external systems by direction + name, specifications by identity.
  • Hotspots always union, whatever the rungs. They are annotations rather than claims about the system, and arbitrating them would discard findings.
  • Any dropped claim becomes a SourceDisagreement hotspot naming the role, both claims and the rung each came from — so a losing claim is recorded rather than silently discarded. Where the sources stamped Origin, it names which source rather than only the rung, which matters most when they share one.
  • Slices fold by name; slice order is first appearance.
  • Aggregates union by type full name.

Ask a merged slice where any role came from with ProvenanceFor:

cs
slice.ProvenanceFor(EventModelRole.EmittedEvents);  // Observed — production claimed these
slice.ProvenanceFor(EventModelRole.Domain);         // Declared — only the overlay claims a domain
slice.ProvenanceFor(EventModelRole.HandlerType);    // null — nothing claims it

Every rendered EventModelElement carries the same answer on its own Provenance, so a viewer can shade "production has seen this happen" differently from "somebody wrote it down" without re-deriving anything.

Merging two slices with different names throws — slices merge by name, and a mismatch means a bug in whoever assembled the list.

A specification already says which command it exercises, so it can say which slice it specifies without anyone typing a link. A spec manifest is a list of SpecificationBindingDescriptors: the spec's {Feature}/{Scenario} identity, the CommandType it sends, and optionally a Domain, a Namespace or an explicit SliceName. Bobcat emits it. The contract lives in JasperFx, so neither Bobcat nor Wolverine depends on the other.

EventModelSpecifications.Link(model, manifest) joins it onto an assembled model:

cs
var linked = EventModelSpecifications.Link(model, manifest);
  • The join key is the command type. A spec links to the one slice whose CommandType is its command.
  • Several slices handling one command -- one per module -- are narrowed by the spec's Domain, then by its Namespace (a prefix of the namespace of the slice's handler, or of any handler on its AdditionalEntryPoints). If that leaves exactly one, the spec links there. Otherwise it links nowhere, and an UnresolvedSpecification hotspot on the model names the candidates rather than guessing.
  • An explicit SliceName replaces the inference. One naming a slice the model doesn't have is reported the same way.
  • A spec whose command no slice handles is not linked and not reported: it specifies code that doesn't exist yet.
  • Derived links sit on the Specified rung. A hand-typed LinksToSpecification that agrees records nothing. One that the specifications don't include loses, and is recorded as a SourceDisagreement.

The join runs over an assembled model, not as one more source, because it has to see every source's slices to find the one handling a command. The specifications also live in a test assembly the application host never loads, so the join runs in a model command handed that assembly (Wolverine's describe-event-model --specs).

A spec generator writes the manifest into the spec assembly as a type with a static All, marked [assembly: SpecificationManifest(typeof(...))]. SpecificationManifest reads it, with no reference to the tool that wrote it:

cs
// From an assembly already loaded
var manifest = SpecificationManifest.In(typeof(SomeSpec).Assembly);

// From a path, as a --specs flag gets it. Its dependencies resolve from its own .deps.json.
var manifest = SpecificationManifest.Load("tests/Specs/bin/Debug/net10.0/Specs.dll");

var linked = EventModelSpecifications.Link(model, manifest);

It also reads Bobcat.SpecificationManifestAttribute, which Bobcat versions from before the attribute moved into JasperFx write.

Serialization ​

The descriptors are plain records and serialize with System.Text.Json as-is. CritterWatch's wire shape is camelCase with camelCase string enums:

cs
var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};

var json = JsonSerializer.Serialize(model, options);

snippet source | anchor

Because Elements, Edges, Links and PatternViolations are computed properties, they go out on the wire — a viewer gets the rendering contract without a second transform — and are ignored coming back in. A payload whose links disagree with its roles is recomputed rather than believed.

Where the slices come from ​

Four rungs write into this vocabulary, and a slice is usually assembled from more than one:

RungSourceClaims
DeclaredBobcat specs, a curated model file, your overlayNames, domains, chapters, hotspots, spec links
DerivedWolverine's chains; the event store's projection registryCommands, handlers, emitted events, projections, consumed events
ObservedCritterWatch watching a running systemWhat actually happened

The store rung is ProjectionEventModelSource, which reads the registered projections straight out of IEventStore — one View slice per projection, carrying the projection type, the document it produces and every event it applies, plus one aggregate per single-stream projection carrying its identity type. It is registered by the store's own AddMarten / AddPolecat / AddFisher, or directly:

cs
services.AddProjectionEventModelSource();

Slices are named after the document type, which is how a declared View slice is already named — so the spec-declared AccountBalance and the store-derived one merge into one slice carrying both a Declared and a Derived claim, rather than two stickies that say the same thing.

ConsumedEvents on a derived slice is the projection's apply set minus the stream lifecycle events — Archived and Compacted<T>, which every aggregation projection handles whether or not the aggregate declares an Apply for them. They are honest about what the projection handles and are not what a canvas means by the events a read model consumes: no command slice emits them, so they would render as stickies that link to nothing. SubscriptionDescriptor.AppliedEvents still carries them for consumers asking the wider question.

A derived slice names one document, from SubscriptionDescriptor.AggregateType, so an event projection or a composite gets no slice of its own. SubscriptionDescriptor.PublishedTypes is the list for a consumer that wants every type a projection writes: the aggregate document, each document an event projection creates, what a composite's members write, flattened, and anything the composite names for teardown itself. It is distinct, ordered by full name, and empty for a subscription. Empty does not always mean "writes nothing", because a flat table projection writes rows with no type behind them, and a bare IProjection never says what it writes. An EF Core projection reports its entity type, which belongs to its DbContext rather than to the store's document mappings.

Released under the MIT License.