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.

SlotFilled byHolds
NameBothDisplay name; also the merge key across sources
PatternDerivedCommand, View, Automation or Translation
TriggerKindDerivedHttp, Grpc, MessageHandler, JobScheduler, Human, External
TriggerOriginDerivedHTTP verb + route, 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 handler or endpoint type — distinct from the aggregates
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
ExternalSystemsDerivedSystems on either end of a translation
SpecificationsDerived (mostly)Bound specs by {Feature}/{Scenario} plus resolved types
HotspotsBothPending specs (derived) and prose (overlay)
DomainOverlayBounded context
ChapterOverlayA named span of slices — the navigation unit above Domain

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.

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 overlay is *not* allowed to fill.
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))]
};

snippet source | anchor

EventModelDescriptor

The whole model: its Slices, the Aggregates those slices reference by type (each with its kind and applied events), model-level Hotspots, and the cross-slice Links.

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.

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

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 three-rung ladder of authority, EventModelProvenance:

RungWhoBeats
DeclaredA Gherkin spec, a code-first spec, the EventModelDefinition overlay
DerivedWolverine's handler / HTTP / gRPC chains, the source generatorDeclared
ObservedCritterWatch watching a running systemDerived, Declared

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.
  • 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.

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 and Links 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. 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 eventsArchived 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.

Released under the MIT License.