Skip to content

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 clrTypeName is 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.ExecuteAsync reconciled 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. Its DetectRenamesAsync remains a handy non-destructive diagnostic.

Renaming a type — the workflow

  1. Rename the type in code (e.g. OrderCreatedEventOrderPlacedEvent). Keep its [PinnedId].
  2. Build. WHIZ120 fails: the ledger still records OrderCreatedEvent for that pinned id.
  3. In .whizbang/pinned-type-ledger.json, add MyApp.Contracts.OrderCreatedEvent to that entry's formerNames and set its clrTypeName to MyApp.Contracts.OrderPlacedEvent.
  4. Build again. WHIZ120 clears, and the generator now emits the alias so old OrderCreatedEvent events deserialize to OrderPlacedEvent.
  5. Commit the ledger change alongside the rename — the diff is the acknowledgment.
  6. Deploy. On startup each environment's registry reconcile sees the old name recorded as a former name and updates its wh_message_type_registry row 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.