Skip to content

Generation validation

Chronicle enforces rules about how event type generations and their migration chains must be structured. These rules exist to keep your event history consistent and to prevent silent data loss caused by incomplete migration definitions.

When you register an event type at generation 2 or higher, Chronicle validates that:

  1. A generation 1 must exist — every event type begins at generation 1. There is no such thing as an event that starts at generation 2.
  2. Generations must be sequential with no gaps — if a type is at generation 3, you must have migrators for 1→2 and 2→3. Jumping directly from 1 to 3 is not allowed.
  3. Every step in the chain must have a migrator — a migrator must be present for every consecutive generation pair from 1 up to the current generation.

A migration chain that satisfies these rules is called a valid chain.

Gen 1 ──(1→2 migrator)──▶ Gen 2 ──(2→3 migrator)──▶ Gen 3
Gen 2 ──(2→3 migrator)──▶ Gen 3 ✗ no 1→2 migrator

This throws MissingFirstGenerationForEventType.

Gen 1 Gen 3 ✗ no 1→2 or 2→3 migrators

This throws MissingMigrationForEventTypeGeneration.

[EventType(generation: 3)] ✗ no migrators defined

This throws MissingEventTypeMigrators.

When a new generation adds a property that did not exist in older events, declare a default value for it in the upcast. Chronicle applies this default to any event stored before the property was introduced.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Migrations;
[EventType("validation-author-registered", generation: 2)]
public record MigrationsValidationAuthorRegistered(string Name, string Status);
[EventTypeGenerationFor<MigrationsValidationAuthorRegistered>(1)]
public record MigrationsValidationAuthorRegisteredV1(string Name);
public class MigrationsValidationAuthorRegisteredMigration : EventTypeMigration<MigrationsValidationAuthorRegistered, MigrationsValidationAuthorRegisteredV1>
{
public override void Upcast(IEventMigrationBuilder<MigrationsValidationAuthorRegistered, MigrationsValidationAuthorRegisteredV1> builder) =>
builder.Properties(pb => pb
.DefaultValue(t => t.Status, "active")); // Name is unchanged between generations — no operation needed for it
public override void Downcast(IEventMigrationBuilder<MigrationsValidationAuthorRegisteredV1, MigrationsValidationAuthorRegistered> builder)
{
// Status does not exist in gen 1 — no mapping needed
}
}

DefaultValue tells Chronicle: “if this property is absent from the event when upcasting, fill it with this value.” Properties that already carry a value are left unchanged.

Once a generation’s schema is registered with the Kernel, it cannot be changed. If you modify an event record without bumping its generation, Chronicle detects that the schema no longer matches what was originally stored and throws:

Cratis.Chronicle.Services.Events.EventTypeSchemaChanged:
Event type 'AuthorRegistered' at generation 2 has a schema that differs from the already registered schema.
Schema changes are not allowed without creating a new generation.

The fix is always to introduce a new generation and a corresponding migrator rather than silently mutating an existing one.

Immutability is about what an already stored event means, so two kinds of change to an enum are accepted in place and update the registered schema rather than being rejected:

  • Gaining a member. Nothing already stored carries the new value, so nothing already stored changes meaning.
  • Renaming a member. An event’s payload carries the underlying number, not the name; the name is resolved through the schema when the value is read. An enum mirroring an external system is renamed on that system’s schedule, and Chronicle does not make you cut a generation for a spelling you do not control.

Two changes remain breaking, because both leave already stored values denoting something other than what they denoted when they were written:

  • Removing a member — stored events carrying that value now denote nothing.
  • Renumbering a member — the names survive but move onto different numbers, so every stored event silently reads as a different member.

For those two, cut a new generation and declare a value map in the migration saying what the old values became. Chronicle applies it forward when upcasting and inverted when downcasting.

Everything outside enums — a property added, removed, retyped, or renamed — is unchanged: still a schema change, still needing a new generation.

Strict validation is always enforced in the production image of the Kernel. The development image relaxes this by honouring the EnableEventTypeGenerationValidation flag sent from the client.

EnableEventTypeGenerationValidation defaults to false in ChronicleOptions, so no extra configuration is needed during early development. When your event schemas are stable, opt into strict validation by setting it to true:

using Microsoft.Extensions.Hosting;
public static class MigrationsValidationEnableValidationRegistration
{
public static void Configure(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.AddCratisChronicle(configureOptions: options =>
{
options.EnableEventTypeGenerationValidation = true;
});
}
}

Alternatively, set it in appsettings.json under the Cratis:Chronicle section:

{
"Cratis": {
"Chronicle": {
"EnableEventTypeGenerationValidation": true
}
}
}

This value is forwarded to the Kernel as part of the event-type registration request. The Kernel only honours it when running the development image — the production image always validates unconditionally regardless of what the client sends. This makes it impossible to accidentally disable validation in a production deployment.

Client-side checks, before anything reaches the Kernel

Section titled “Client-side checks, before anything reaches the Kernel”

The rules above are all enforced by the Kernel during registration. Two further checks run entirely in your own process, the moment a migrator is constructed or discovered — before a registration request is ever sent:

  • MigrationGenerationsMustShareEventTypeId — thrown from EventTypeMigration<TUpgrade, TPrevious>’s constructor if TUpgrade and TPrevious resolve to different event type ids. This is what catches the classic mistake with the old, explicit-id style: an id omitted on the previous generation (silently defaulting to the CLR type name) or a typo that makes the two ids differ. Marking the previous generation with [EventTypeGenerationFor<T>] instead removes the mistake structurally, since there is no independent id to mistype in the first place.
  • MultipleMigratorsForSameEventTypeGeneration — thrown when discovering migrators if two different migrator classes both claim to bridge the same generation pair for one event type. Exactly one migrator may own a given transition.

The code analysis rules CHR0037 and CHR0049 catch the same class of mistake at compile time, before you even run the application.

Validation runs on the Kernel during Register(), which is called when the client connects to Chronicle. If validation fails the server throws an exception that the client receives as an RpcException. This is intentional — failing fast at startup is far better than discovering a broken migration chain at runtime.

ExceptionCondition
MissingEventTypeMigratorsEvent type is at gen ≥ 2 but no migrators are defined
MissingFirstGenerationForEventTypeNo migrator covering the path from generation 1 exists
MissingMigrationForEventTypeGenerationA specific step in the chain (N → N+1) is missing
InvalidMigrationPropertyForEventTypeA migration references a property that does not exist in the expected generation’s schema
EventTypeSchemaChangedAn existing generation’s schema has changed; create a new generation instead