Pinned-Type Ledger: Safe, Governed Type Renames¶
Verified by tests
PinnedTypeRenameAnalyzerTests, MessageJsonContextRenameAliasTests, PinnedTypeLedgerGeneratorTests, MessageTypeCatalogGeneratorTests, DapperMessageTypeRegistryPopulatorTests, EFCoreMessageTypeRegistryPopulatorTests, TypeRegistryMetricsTests — library CI run #31657041675 (2026-08-13)
A pinned type's identity is its [PinnedId] — a stable GUID — not its CLR name. The CLR name is a
versioned label. But messages are written into an append-only event store with the name of the day, and
name → Type resolution (JsonContextRegistry.GetTypeInfoByName) looks types up by that stored name. So if you
rename OrderCreatedEvent to OrderPlacedEvent and do nothing else, every already-stored OrderCreatedEvent
becomes unreadable — the resolver returns null and deserialization throws.
The pinned-type ledger makes renames safe and governed. It is a committed lockfile — .whizbang/pinned-type-ledger.json,
committed at the .whizbang/ root (the regenerable message-registry.json lives separately in the git-ignored
.whizbang/cache/ subfolder) — that records, per pinned id, the type's current CLR name and every
former name it has had:
{
"version": 1,
"types": [
{
"pinnedId": "11111111-2222-3333-4444-555555555555",
"clrTypeName": "MyApp.Contracts.OrderPlacedEvent",
"kind": "event",
"formerNames": ["MyApp.Contracts.OrderCreatedEvent"]
}
]
}
Like package-lock.json, it is generated by tooling, reviewed as a diff, and committed. Three build-time
components read and maintain it — all AOT-safe, all opt-in.
The four components¶
1. Ledger generator (bootstrap + monotonic merge)¶
PinnedTypeLedgerGenerator discovers every [PinnedId] message and perspective type in the assembly, merges
it with the committed ledger, and emits the merged JSON as a C# constant (PinnedTypeLedger.g.cs). An MSBuild
target extracts that constant to .whizbang/pinned-type-ledger.json after each build.
The merge is a monotonic lockfile update:
- New pinned ids are added with the current name and empty
formerNames, so every type is governed from birth. - Existing entries are preserved verbatim — their
clrTypeNameis never refreshed to the current name. That is deliberate: it is exactly what makes a rename detectable (see WHIZ120 below). - Orphan entries (a pinned id with no living type) are retained; WHIZ121 surfaces them for you to prune.
The first build in a newly-adopting project bootstraps the file; commit it, and governance is active.
2. Governance gate (WHIZ120 / WHIZ121)¶
PinnedTypeRenameAnalyzer diffs the compiled [PinnedId] types against the committed ledger:
- WHIZ120 (Error) — a pinned id's current CLR name is neither the
ledger's recorded name nor one of its
formerNames. That is an un-acknowledged rename; the build fails until you record it. - WHIZ121 (Warning) — a ledger entry whose pinned id no longer exists in the compilation (a removed type or a changed id).
The analyzer is inert when no ledger is present, so adoption is opt-in per project.
3. Alias resolution (former events still deserialize)¶
MessageJsonContextGenerator reads the ledger and, for every former name a message type has had, emits an extra
JsonContextRegistry.RegisterTypeName("<former>, <assembly>", typeof(<current>), …) (bare and
MessageEnvelope<T> forms). Events stored under the old name now resolve to the current type. Aliases are only
emitted for messages (events/commands) — perspective types are not deserialized from the log by name, so they need
no alias (their rename is still gated by WHIZ120).
4. Registry reconcile (the catalog self-heals every environment)¶
Whizbang keeps a per-environment catalog of every pinned type in wh_message_type_registry (one row per pinned id,
holding the CLR name that environment's data was last populated with). After a rename, that row lags: it still holds
the old name. The startup reconcile (reconcile_message_type_registry) is now ledger-aware — the compile-time
IMessageTypeCatalog carries each type's FormerNames (attached by MessageTypeCatalogGenerator from the ledger),
and the reconcile function uses them:
- Acknowledged rename — the stored (old) name is a recorded former name → the row is updated old → new in
place (action
renamed). Because the rename was reviewed and committed to the ledger, healing it is safe. - Unacknowledged drift — the stored name is not a recorded former name → the row is left untouched (action
drift_detected, logged as a warning). An accidental or ungoverned rename must be recorded in the ledger first.
This runs automatically on every service startup, so each environment self-heals — development, staging, and
production environments converge without a manual, per-environment step. It is non-destructive (only the registry's
clr_type_name is touched; stored event data is not rewritten, and the ledger remains the sole history) and
idempotent. It is also backward-compatible during a rolling deploy in both directions (an old service against the
new function sends no former names and simply falls back to drift_detected; a new service against the old function
has its extra field ignored).
To audit drift ahead of a deploy, diff the committed ledger against a target environment's registry by pinned id: a name in the registry that is neither the ledger's current name nor a recorded former name is unacknowledged drift.
Observability. The reconcile emits metrics on the Whizbang.TypeRegistry meter, each tagged by service:
whizbang.type_registry.renamed (rows healed old → new for an acknowledged rename) and
whizbang.type_registry.drift_detected (un-acknowledged drift left untouched). Alert on drift_detected > 0 — it
means a type was renamed without recording it in the ledger. Every reconcile outcome is also logged (renames at
Information, drift at Warning).
Legacy note. Before the ledger existed,
IEventTypeRenameTool.ExecuteAsyncreconciled drift by rewriting stored type names across every data table — a destructive, manual step. That apply path is now[Obsolete]: the ledger-aware reconcile heals the registry non-destructively and the former-name aliases keep old events readable, so no data rewrite is needed. ItsDetectRenamesAsyncremains a handy non-destructive diagnostic.
Renaming a type — the workflow¶
- Rename the type in code (e.g.
OrderCreatedEvent→OrderPlacedEvent). Keep its[PinnedId]. - Build. WHIZ120 fails: the ledger still records
OrderCreatedEventfor that pinned id. - In
.whizbang/pinned-type-ledger.json, addMyApp.Contracts.OrderCreatedEventto that entry'sformerNamesand set itsclrTypeNametoMyApp.Contracts.OrderPlacedEvent. - Build again. WHIZ120 clears, and the generator now emits the alias so old
OrderCreatedEventevents deserialize toOrderPlacedEvent. - Commit the ledger change alongside the rename — the diff is the acknowledgment.
- Deploy. On startup each environment's registry reconcile sees the old name recorded as a former name and updates
its
wh_message_type_registryrow old → new automatically — no per-environment cleanup.
The VSCode extension reads the same
.whizbang/files and can perform steps 3 above inline, surfacing identity and rename history without a manual edit.
Wiring & opt-out¶
The Whizbang generators package auto-includes the ledger as an AdditionalFile and registers the extraction target.
Nothing else is required. To opt a project out, set:
<PropertyGroup>
<WhizbangIncludePinnedTypeLedger>false</WhizbangIncludePinnedTypeLedger>
</PropertyGroup>
Why not just refresh the name automatically?¶
Because a silent refresh would erase the one signal that tells you an immutable event log now contains a name the running code no longer knows. The ledger is intentionally append-only for names: renames are cheap, but they are never invisible. See also Type Formatting for the two canonical type-name forms the ledger and event store use.