- Status: Draft
- Tier: Stable
- Author: AI assistant review
- Created: 2026-07-21
- Resolved: (pending)
- Language-version at effect: 2.0 (planned)
- Supersedes: none
- Superseded by: none
Define Stable language and runtime support for evolving durable entity schemas over time. A migration contract attached to an entity declares how to upgrade historical snapshot state and event-journal entries from an older schema version to the current one. Migrations are pure functions, applied lazily during replay, and recorded in the entity's metadata so that the same entity code can load journals written years ago.
Durable entities are designed to live for years or decades. During that lifetime, their state schema and event definitions will change: fields will be added, removed, renamed, or retyped; new event types will appear; old event types will become obsolete. Without a migration mechanism, every schema change would break the recovery of existing entities and force manual data migration.
A migration contract solves this by:
- Keeping the source of truth intact. The event journal is never rewritten in place.
- Applying upgrades lazily. On recovery, the runtime replays the journal through the chain of migrations until the current schema version is reached.
- Making evolution explicit. Migrations are code, reviewed and versioned like any other behavior.
- Preserving determinism. Migrations are pure functions, so replay remains deterministic.
Every entity has an implicit or explicit version:
entity BankAccount {
version: 3
state balance: Int = 0
state currency: String = "USD" // added in v2
state overdraft_limit: Int = 0 // added in v3
events
| Deposited(amount: Int)
| Withdrawn(amount: Int)
| CurrencyChanged(currency: String)
}
Rules:
versionis a positive integer. It defaults to 1 if omitted.- The version is recorded in every snapshot and journal segment header.
- A journal segment without a version field is treated as version 1.
- The version applies to both the state schema and the event schema.
A migration block declares a pure function that upgrades state and events from one version to the next:
entity BankAccount {
version: 2
state balance: Int = 0
state currency: String = "USD"
events
| Deposited(amount: Int)
| Withdrawn(amount: Int)
| CurrencyChanged(currency: String)
migration from 1 to 2 {
state => {
// Add the new field with a default value.
self.currency = "USD"
}
events {
| Deposited(amount) => {
emit Deposited(amount)
emit CurrencyChanged("USD")
}
| Withdrawn(amount) => emit Withdrawn(amount)
| other => other
}
}
}
Syntax:
migration from V to WwhereW == V + 1.- Optional
state => { ... }clause for upgrading snapshot state. - Optional
events { ... }clause with event handlers. - Multiple
migrationblocks can be declared to form a chain: 1→2, 2→3, 3→4.
Rules:
- Migrations are pure: they may not perform effects, send messages, access the clock, or read external state.
- Inside a migration,
selfrefers to the entity state at the old version. Assignments toselfupgrade the state in place. emitinside a migration appends zero or more upgraded events to the replay stream; it does not mutate the persistent journal.- Event handlers may use a wildcard catch-all (
| other => other) to pass through unchanged events. - It is a compile-time error if a migration is declared for a version that does not match
W == V + 1or if there is a gap in the chain.
When an entity recovers from a snapshot and journal:
- Read the snapshot version
S. - Apply
migration from S to S+1, thenS+1 to S+2, ..., up to the current entity versionC. - Replay journal events starting from the snapshot offset.
- For each journal event at version
J, apply migrations fromJtoCbefore running the entity'sapplyhandlers. - If the snapshot version equals the current version, no state migrations are needed; only journal events may need migration.
Migrations are applied in-memory. The persistent journal is not rewritten.
Migrations must be deterministic functions of their input. The compiler and runtime enforce this by:
- Forbidding effectful operations inside migration blocks.
- Forbidding
perform,send,spawn,ask,after,until, and external function calls. - Allowing only pure Nulang expressions, record/variant construction, and
emitto the replay stream.
This restriction ensures that replay produces the same state on any conforming runtime.
For common additive changes, the compiler can generate a default migration:
- Adding a new state field with a literal default value.
- Adding a new event type that does not affect existing state.
Explicit migrations are required for:
- Renaming or removing fields.
- Changing field types.
- Splitting or merging event types.
- Deriving new state from historical events.
The compiler emits a warning when an entity's version increases without a corresponding explicit or default migration.
- Each entity carries a list of its migrations in the compiled artifact metadata.
- The LSP can show which migrations apply to an entity and flag missing migration clauses.
- A future CLI tool (
nulang migrate --check) can validate that all entities with a version > 1 have complete migration chains.
Version 1 event:
events | Deposited(amount: Int)
Version 2 splits amount into whole and fractional units:
events | Deposited(whole: Int, fractional: Int)
migration from 1 to 2 {
events {
| Deposited(amount) => emit Deposited(amount / 100, amount % 100)
| other => other
}
}
src/ast.rs: AddMigrationBlock,MigrationClause, and version fields toDecl::Entity.src/parser.rs: Parseversion: Nandmigration from V to W { ... }.src/typechecker.rs: Verify migration purity and event/state compatibility.src/effect_checker.rs: Reject effectful operations inside migrations.src/hir_lower.rs: Desugar migrations into pure upgrade functions.src/mir_lower.rs/src/mir_codegen.rs: Generate migration application during replay.src/runtime/actor.rs: Store entity version in actor state and invoke migrations on recovery.src/runtime/persistence.rs: Read and write versioned snapshots and journal segments.src/stdlib.rs: No new effects needed; migrations are pure code.
- Tier: Stable.
- Frozen Core impact: None.
- Breaking change: No. Migrations are additive.
- Relationship to other RFCs: Depends on RFC 0005 (Durable Entities) and RFC 0007 (Event Sourcing Primitives). Migrations upgrade both the state schema and event schema defined in those RFCs.
This RFC is additive. Existing persistent actor and actor programs are unaffected. Entities without explicit versions default to version 1 and have no migrations.
- Offline migration tools that rewrite the journal. Rejected because rewriting the source of truth loses historical data and is risky for long-lived entities.
- Automatic schema inference and coercion. Rejected because silently coercing old events can hide semantic changes (e.g., units changing from cents to dollars).
- Migration as a separate top-level declaration. Rejected because attaching migrations to the entity keeps the evolution story local and makes it clear which entity a migration belongs to.
- Allow effectful migrations. Rejected because it would break deterministic replay and make auditing impossible.
- Should migrations be allowed to access the old entity code, or only the old state/events? Accessing old code complicates versioning but may be needed for complex transformations.
- Should migrations support downgrades (e.g.,
migration from 2 to 1)? Downgrades are useful for rollback but significantly complicate the model. - Should the runtime cache migrated journal segments to avoid re-applying migrations on every recovery?
- Should migrations be unit-testable in isolation by feeding them old events/state?
(To be filled on accept/reject.)