ADR 0008: Migration ergonomics, client-driven migration, and legacy adoption
- Status: Proposed; amended 2026-09-30 for ABI 0.21 (see Amendment)
- Date: 2026-09-10
- Owners: Pina maintainers
- Related: ADR 0007
Context
ADR 0007 established the versioned ABI, on-demand account migration, and the publication ledger. Dogfooding the full developer loop while advancing the migrations example to a second generation confirmed the core experience:
- opting in is one annotation and one
pina.tomlsetting; - forgetting
pina migrations createfails the build with the exact remedy; - drafts are replaceable, published history is pinned, and
deployrecords publication automatically; - inline migration works with an explicitly capped payer.
Three gaps remain between that experience and the intended mental model — “turn migrations on, then stop thinking about them”:
- A stale account whose business instruction carries no authorized payer cannot migrate. Every instruction that touches a migratable account must thread a
migration_payerslot through its process contract, or the instruction fails withMigrationRequired. ADR 0007 describes a stable migration instruction that clients invoke before the real operation; that instruction was never implemented. - Clients have no migrate-first flow. Generated clients know each account’s current version (the IDL already carries it as an omitted constant) but never act on it.
- Programs launched before migrations cannot adopt them. ADR 0007 explicitly refuses to interpret unversioned bytes as version zero, so an existing program that adds
migrationsstrands every live account.
Decision
Reserved migration instruction
Pina reserves one instruction discriminator per program — the all-ones value of the program’s instruction discriminator width — for a framework-generated Migrate instruction. The #[discriminator] macro rejects a user variant that claims the reserved value. Because no Pina program is live today, the reservation is not a breaking change; it is part of the migrations feature contract from its first release.
When a program declares at least one migratable account, the framework generates:
pub mod __pina_migrate {
// data: [reserved discriminator] (no payload)
// accounts:
// [0] payer — writable signer, optional when no step needs funding
// [1..] program-owned accounts to migrate, each self-describing
// through its account discriminator
pub fn process(
program_id: &Address,
accounts: &mut [AccountView],
data: &[u8],
) -> ProgramResult;
}
The handler dispatches each account by its leading discriminator to the matching MigratableAccount implementation and runs the same executor the inline path uses, with the same step, growth, and lamport caps. Accounts whose discriminator matches no migratable contract, accounts not owned by the program, and duplicated mutable accounts fail closed. The developer wires one match arm in the program entrypoint (Migrate => __pina_migrate::process(...)) so instruction routing stays explicit, and pina migrations create fails the build until that arm exists.
This is the instruction clients prepend when an account is stale: the payer authorizes exactly the migration cost, then the business instruction runs without migration plumbing in its own account list. Business instructions keep their inline-migration behavior; the dedicated instruction is the escape hatch for payers that the historical request could not name.
Client migrate-first flow
Generated clients gain a migrateIfNeeded helper per program:
- fetch the program-owned accounts named by the operation;
- compare each account’s version envelope against the client’s frozen current version (already embedded in the IDL as an omitted constant);
- build a transaction of
[Migrate { payer }, ...real instructions]when at least one account is stale, otherwise send the real instructions alone.
The helper never migrates silently at rest: migration happens inside a transaction the caller signs, with the payer the caller chose, and the real operation follows in the same transaction so it observes current data or the whole transaction fails.
Legacy adoption
A program that launched without migrations adopts them with #[account(discriminator = ..., migrations, legacy)]:
- The first
pina migrations createrecords alegacyBaseschema — the pre-migration wire layout without the version envelope — alongside version zero, whose payload is the legacy payload unchanged. - The generated planner recognizes legacy bytes before any versioned layout: exact-shape validation of the legacy base runs last, after every versioned current and historical layout has had its exact chance. Two layouts accepting the same bytes is an ambiguity error at generation time, so runtime detection stays deterministic.
- The legacy-to-v0 transition is generated: insert the version envelope (version zero) and shift the payload; growth follows the ordinary rent and payer rules.
- Instructions and events adopt the same way. Unversioned historical instruction data is shorter by the version width, which the existing exact-length historical replay already distinguishes.
Amended (ABI 0.21): instructions and events no longer adopt through a legacy-to-v0 transition. See the Amendment.
Existing accounts migrate on their next touch with no client change; a client that keeps sending legacy instruction data keeps working until the program retires that history explicitly.
Amendment (ABI 0.21, 2026-09-30)
ABI 0.21 changes what legacy adoption has to cover. Accounts are unaffected: the reserved Migrate instruction, the client flow, and the legacy design above still apply to them.
- Instructions. Under an
autopolicy an instruction is recorded as a snapshot without an envelope, so its wire format stays[discriminator][payload], the same bytes a program launched before migrations accepts. Covering a launched program’s instructions withautotherefore needs no bridge: the snapshot records the live payload and gates later changes. Only an instruction that opts in with#[instruction(discriminator = X, migrations)]gains an envelope, which changes its live wire format, so it still needs a new discriminator or a deliberate bridge. Once an instruction is published as a snapshot,createrefuses to add the envelope (EnvelopeAddition). - Events. Events are versioned, not migrated: the program emits only the current version and generated clients decode each historical version with its own schema. A legacy event therefore needs a generated decoder for its unversioned layout rather than a legacy-to-v0 transition.
- Opting in. The context above describes opting in with one annotation and one
pina.tomlsetting. The policy now lives only in the manifest:pina migrations create --autorecords it and--version-typerecords the width, andpina.tomlrefuses the retired[migrations].autoand[migrations].version_typekeys. The mental model is unchanged: turn migrations on once, then stop thinking about them.
Security consequences
- The reserved discriminator removes a value from every program’s instruction space; the discriminator macro enforces the reservation at compile time.
Migrateis a privileged-sized surface: it mutates only program-owned accounts, caps lamports per invocation, and refuses accounts that fail ownership or discriminator checks before any mutation.- Client
migrateIfNeededdecisions are advisory; the on-chain boundary re-validates every version and shape, so a stale or malicious client cannot downgrade data by skipping migration. - Legacy detection must be unambiguous by construction: generation fails on any byte-shape overlap between the legacy base and a versioned layout.
Open questions
- Should
Migrateaccept a maximum-lamports argument, or is the generated per-program cap sufficient? (Current inline path uses a compile-time cap.) - Should
migrateIfNeededbatch multiple stale accounts into oneMigrateper transaction, or one per account to bound per-transaction rent exposure? - Does legacy adoption need an off-chain bulk-migration command (
pina migrations sweep) for programs that want to pre-migrate instead of migrating on touch?
Implementation order
- Reserved discriminator enforcement in
#[discriminator]and theMigratehandler generated for migratable programs. migrateIfNeededin the Rust client, then TypeScript and Dart.legacyadoption: manifestlegacyBase, planner detection, generated legacy-to-v0 transition, and an adopted-after-launch example test.