A chart hash is the identity of one complete declaration, not a release label. When that hash changes, retained instances do not automatically become instances of the new chart. Migration is the explicit, durable edge between those two meanings.
Treat migration as a versioned domain operation
Deploying v2, registering v2, and migrating an instance to v2 are separate operations. A process can know the new chart while durable authority still says a particular instance belongs to v1.
Declare + restore + commit
Change the model without rewriting the past.
Move one Account from an exact v1 head to an exact v2 restoration, including the Timer obligation that remains live across the boundary.
1 · Declare one exact edge
A migration names both chart versions.
Migration.make binds one source hash to one destination hash under the same chart name. The callback receives a Snapshot decoded by v1 and returns only a restoration constructed and validated by v2.
import { Migration, Selection } from "@motive/motive";
import { Effect, Option, Schema } from "effect";
import { AccountV1Chart, AccountV2, AccountV2Chart, accountV1 } from "../model.ts";
export const AccountV1ToV2 = Migration.make({
from: AccountV1Chart,
to: AccountV2Chart,
rejection: Schema.Never,
migrate: Effect.fn(function* (source) {
const account = Option.getOrThrow(Selection.option(accountV1, source.snapshot));
return yield* AccountV2Chart.restore({
_tag: "Running",
configuration: [new AccountV2({ availableBalance: account.balance })],
history: Migration.History.clear(source.history),
dispositions: [],
});
}),
});
2 · Disposition retained obligations
State data is only part of the retained world.
The active ReviewDeadline is an exact source-owned Timer obligation. retain maps that opaque token to one compatible v2 occurrence; History is cleared explicitly. Missing or duplicate dispositions refuse preparation.
import { Migration, Selection } from "@motive/motive";
import { Effect, Option, Schema } from "effect";
import {
AccountV1Chart,
AccountV2,
AccountV2Chart,
ReviewDeadlineV1,
ReviewDeadlineV2,
accountV1,
} from "../model.ts";
export const AccountV1ToV2 = Migration.make({
from: AccountV1Chart,
to: AccountV2Chart,
rejection: Schema.Never,
migrate: Effect.fn(function* (source) {
const account = Option.getOrThrow(Selection.option(accountV1, source.snapshot));
const deadline = Option.getOrThrow(source.timer(ReviewDeadlineV1));
return yield* AccountV2Chart.restore({
_tag: "Running",
configuration: [new AccountV2({ availableBalance: account.balance })],
history: Migration.History.clear(source.history),
dispositions: [Migration.retain(deadline, ReviewDeadlineV2)],
});
}),
});
3 · Commit against the exact head
Migration becomes one durable step—or no step.
StatechartMigration.migrate compares the current head with v1, prepares the v2 checkpoint and planned work, then commits v2 sequence 8 with a storage-owned Migrate delivery. A retry sees the exact destination and returns AlreadyMigrated.
import { Statechart } from "@motive/motive";
import { StatechartMigration } from "@motive/motive-storage";
import { Effect, Schema } from "effect";
import { AccountV1ToV2 } from "./beats/beat-2.ts";
export const migrateAccount = Effect.fn("Account.migrate")(function* (rawId: unknown) {
const instanceId = yield* Schema.decodeUnknownEffect(Statechart.RootInstanceId)(rawId);
return yield* StatechartMigration.migrate({
declaration: AccountV1ToV2,
instanceId,
});
});
Declare one exact source-to-destination edge
Migration.make binds from, to, a rejection Schema, and one Effectful transform. Both charts
must have the same chart name because they describe versions of the same entity type. Their
chartHash values identify the exact source and destination declarations.
The callback never receives an untyped checkpoint. The protocol first decodes the retained Snapshot and resume plan through the declared source chart. If stored bytes do not satisfy that exact contract, preparation fails before application migration code runs.
The transform returns a value produced by to.restore. That constructor applies every destination
State Schema, including defaults and transformations, canonicalizes the configuration, and checks
the same topology law as a published Snapshot. It does not run entry Actions. A migration is a
restoration of committed meaning, not a synthetic event that walks ordinary transition behavior.
The declaration's rejection Schema owns expected application refusal. A rejected migration is
operational evidence that the version edge could not be applied; it does not turn the instance into
an authored destination Error State.
Transform domain data without constructing a Snapshot
Use source-owned Selections and Queries to read typed values from source.snapshot, then provide
destination constructor inputs in configuration. A migration source Snapshot is decoded by the
source chart, but it is not implicitly refined to every possible active perspective. Use
Selection.option(sourceSelection, source.snapshot) and handle absence explicitly before reading
perspective-owned data. The destination chart constructs each State value. Application code never
supplies instance id, ancestry, sequence, chart hash, provenance, or durable lifecycle identity
through the restoration.
That boundary prevents a transform from copying an old Snapshot-shaped object and editing a few fields. Such an object would preserve exactly the hidden assumptions migration exists to change. Instead, v1 owns decoding and v2 owns construction.
The destination can be Running or one of the terminal Snapshot results. Conversely, a terminal
source can migrate because migration is an exact-head storage operation rather than ordinary chart
admission. A parked root remains fenced: resolve or supersede its incident under Park policy before
attempting a version change.
Disposition every retained obligation
The source resume plan contains more than domain configuration. It may own live Timers, Activities, children, Resource providers, Resource grants or pending acquisitions, and History records. The migration callback receives lookup functions that issue opaque tokens only for exact obligations present in that decoded source.
Every source obligation must receive exactly one valid disposition:
cancelends an exact Timer, Activity, or child obligation;retaincarries an exact obligation to one compatible destination occurrence;replaceends an exact Timer, Activity, or child and creates fresh destination work;releasecloses an exact Resource grant or pending acquisition; andMigration.History.clear(source.history)explicitly acknowledges and removes source History.
Resource providers and retained grants require compatible destination ownership and contracts. Timer, Activity, and child retention or replacement must resolve to the right destination mode, occurrence, and key shape. Missing, duplicated, ambiguous, incompatible, or invented dispositions fail preparation; a loose object cannot become lifecycle authority.
History is deliberately not reinterpreted against new geometry. The current public disposition is explicit clearing of the exact source History collection. If product policy needs semantic History translation later, that requires its own declared law rather than accidental path-name reuse.
Commit one exact head atomically
StatechartMigration.migrate reads the current instance authority. The head must match the
declaration's source hash. If it already matches the destination hash, the operation returns
AlreadyMigrated with the current sequence. Any third hash is a real
MigrationCurrentChartMismatch, not permission to guess a path.
Preparation builds the destination checkpoint, the storage-owned Migrate step, and any planned
outbox work. Commit compares the same source chart hash and sequence it prepared from. The
destination checkpoint, sequence, migration evidence, and planned work become visible in one
transaction or none of them do.
The migration step advances sequence by one and records the source chart hash, source sequence, and
optional tree epoch. Migrate is intentionally absent from the chart's executable delivery union:
no State can handle it, no caller can send it as an ordinary event, and Park replay cannot pretend it
was chart behavior.
The operation itself receives new ExternalAdmission provenance and captures ambient application
correlation. Any intents created by the destination restoration derive their causation from that
committed migration step under the destination chart hash.
Migrate ownership trees leaves-up
A parent cannot safely claim new child geometry while a retained child still has unexplained old
authority. StatechartMigration.migrateTree opens a durable root migration fence, selects exact
child declarations through application topology policy, and recurses through selected descendants.
Each selected child commits before its parent under one shared migration epoch.
The fence blocks ordinary commits from crossing the changing ownership boundary. If the process
fails after a descendant commits, retry reads that descendant's latest durable Migrate step as a
same-epoch acknowledgement and continues upward. Already committed leaves do not migrate twice.
Returning Option.none from the child resolver is a policy decision to leave that immediate child
at its current version. It also stops recursion through that child; a descendant cannot silently
join the root epoch through an unfenced parent. Cancellation acknowledgements must represent a
durably terminal exact child subtree and be idempotent across retry.
Preserve the historical explanation
Migration adds a bridge; it does not relabel old facts. Checkpoints and steps before the migration
retain their original chart hashes and resolve through their original registered geometry. The new
head and later steps resolve through the destination hash. The Migrate diary entry gives
inspection the exact boundary between them.
Existing incidents likewise remain immutable evidence of the chart hash, sequence, Cause, and provenance under which they occurred. Do not rewrite them to make a deployment look uniform. An operator investigating old behavior needs the old geometry to remain discoverable.
Migration code should therefore be versioned and retained like any other durable decoder. A declaration is useful not only during rollout but whenever a fenced retry, delayed instance, or historical explanation still names that exact edge.
Change the world
- Start a v1 instance, register v2 without migrating it, and prove its head still resolves to v1.
- Migrate the exact v1 head and verify one v2 checkpoint, one
Migratestep, and the same source coordinates become visible atomically. - Retry the same declaration and handle
AlreadyMigrated; then try it against a third chart hash and preserveMigrationCurrentChartMismatch. - Leave one live Timer undispositioned, observe preparation refuse the restoration, then retain it at a compatible v2 occurrence.
- Cut a tree migration after a leaf commits and verify retry reuses its same-epoch acknowledgement before committing the parent.
Return to Observability to inspect source and destination evidence together, or continue to Concepts for the semantic laws behind topology, atomic publication, and identity.