Guide

Migrations

Move exact committed instances between chart versions under explicit restoration laws.

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

Version edgeDeclaration

Names one exact source chart and one exact destination chart.

The chart name agrees; the complete declaration hashes do not.
Destination meaningRestoration

Constructs Schema-valid destination data, topology, and obligations.

It cannot manufacture engine-owned identity or provenance.
Durable authorityMigration step

Atomically replaces one exact head and records the source coordinate.

Old steps remain facts of their own chart versions.

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:

  • cancel ends an exact Timer, Activity, or child obligation;
  • retain carries an exact obligation to one compatible destination occurrence;
  • replace ends an exact Timer, Activity, or child and creates fresh destination work;
  • release closes an exact Resource grant or pending acquisition; and
  • Migration.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

  1. Start a v1 instance, register v2 without migrating it, and prove its head still resolves to v1.
  2. Migrate the exact v1 head and verify one v2 checkpoint, one Migrate step, and the same source coordinates become visible atomically.
  3. Retry the same declaration and handle AlreadyMigrated; then try it against a third chart hash and preserve MigrationCurrentChartMismatch.
  4. Leave one live Timer undispositioned, observe preparation refuse the restoration, then retain it at a compatible v2 occurrence.
  5. 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.