Live Invocation Migration v1
September 12, 2026 · View on GitHub
Status: LOCAL bounded design + reference implementation, fixture-backed.
Audience: implementers of issue #115 ("Migrate live rich state with history and budgets intact") and its neighbours in the #108–#116 lane, and reviewers of the migration boundary this document adds around Live Invocation Contract v1 and Live Invocation Persistence v1.
This document assumes the reader already knows both of those: the causal
journal's record format and ordering rules, kernel::run_live_invocation's
fresh-start/resume/replay/uncertain-intent behavior, and how a journal is
persisted and recovered across a process boundary. Everything below is
additive to that contract, not a restatement of it.
What issue #115 asks for, and why it cannot live inside the kernel
Issue #115 asks for a live invocation to move onto a new ProgramRoot,
schema and deployment policy through a checked pure migration, preserving
conversation history, cumulative budget, and prior turn/call counts. The
prerequisite work this issue names —
execution_revision::typed_migration
— already proves the general shape a real checked migration takes: validate
an actual Suspend, bind old/new state schemas, evaluate a pure migration
function twice and reject any answer that differs between the two calls,
and charge every prior reservation forward rather than resetting it.
This module's file lease is src/live_invocation/** only —
execution_revision, hir and interpreter are frozen and out of reach
here. More importantly, kernel::run_live_invocation itself never holds an
Agent's actual state at all: it threads opaque bytes through the caller's
own TurnObserver/TurnPolicy, and a live invocation's identity and
journal are permanently bound together (journal::validate rejects any
entry naming a different invocation). So "migrate state" cannot mean
"rewrite the kernel's journal in place" — that would defeat the causal
journal's whole contract. src/live_invocation/migration.rs
instead adds one pure function, migrate_live_invocation,
that reads a terminal, suspended predecessor journal and a destination
identity, and produces a durable handoff record plus the destination's
migrated state bytes — never touching the predecessor journal, never
dispatching a model or effect call itself.
The checked-pure seam: LiveStateMigration
pub trait LiveStateMigration {
fn migrate(&mut self, previous_state: &[u8]) -> Result<Vec<u8>, String>;
}
A real deployment binds this to a compiler-checked pure function over
retained source — the exact mechanism
execution_revision::typed_migration::evaluate_migration already
implements against HIR (reject effects, an illegal ownership mode, an
incompatible result shape, or an unbound source identity, before ever
producing a value). This module cannot call into that machinery directly
(it is outside this file lease), so migrate_live_invocation re-states the
one property it verifiably can enforce at this Rust-trait boundary
without HIR in hand: it calls migration.migrate exactly twice on the
identical input and refuses (LiveMigrationError::NonDeterministicMigration)
if the two answers differ — mirroring evaluate_migration's own
first != second rejection precisely. This module ships only deterministic
fixtures (fixture::FixtureStateMigration,
fixture::FixtureNondeterministicStateMigration,
fixture::FixtureRefusingStateMigration); binding a real compiler-checked
migration function is downstream integration work against this trait, the
same declared boundary this lane already draws around
ModelHandler/ProposalDecoder/AuthorizationGate/InvocationBudgetHook.
What "history and budgets intact" means at this layer
- History is intact because migration never touches the predecessor's
journal.
migrate_live_invocationtakes it by shared reference, reads it, and returns.migration::tests::the_predecessors_journal_still_replays_with_zero_dispatches_after_migrationproves this is not merely a documented intention: it migrates a suspended journal, then replays the same journal reference through the ordinarykernel::run_live_invocationwith every seam (TurnObserver,TurnPolicy,ModelHandler) wired to panic if touched, and assertsdispatched == 0andreplay.journal == journal(byte-identical). - Budgets are intact because the predecessor's total committed spend is
carried, never reset.
LiveMigrationHandoff::previous_committed_budgetisbudget::committed_from_journal(previous_journal)— the exact foldbudget::CumulativeBudgetLedger::resumealready uses, extracted once so the two call sites can never drift onto two slightly different folds.CumulativeBudgetLedger::migrated/resume_migratedstart a destination ledger'scommittedat that carried total instead of at zero. A destination ceiling is the new deployment's own independent policy decision (it may narrow or widen); what it can never do is pretend the carried spend did not happen — there is no constructor here that starts a migrated ledger at zero.migration::tests::resuming_the_destination_after_a_simulated_crash_never_refunds_the_carried_predecessor_spendis the fault-injection proof: it builds a destination journal ending in an uncertainRequestIntent(the exact shape a crash between the "before dispatch" write and the "after settlement" write leaves behind — matchingbudget::tests::resuming_after_a_simulated_crash_never_refunds_the_already_committed_reservation's own pattern, crossed over the migration boundary) and showsresume_migratedreconstructscarried + this reservation, refusing a retry that would only fit if either half had been silently refunded. - Prior turn/call counts are intact as carried evidence, not as
continued numbering. A migrated invocation gets a genuinely new
identity and a fresh causal chain starting at turn 0 (identity and
journal are permanently bound; there is no way to splice a new identity
onto an old chain). What issue #115 actually asks for — "A→B→C preserves
previous call counts" — is satisfied by
LiveMigrationHandoffcarryingprevious_turns/previous_model_calls/previous_model_failures/previous_effect_calls(all folded fromjournal::receipt_projection) forward at every hop, so a caller assembling one end-to-end audit trail across A→B→C never loses any hop's counts.migration::tests::an_a_to_b_to_c_chain_preserves_call_counts_and_never_refunds_committed_budgetexercises the full three-generation chain: A migrates into B (a changed rich-state field), B's own subsequent model request observes the migrated field, B migrates into C, and the totals from both hops sum without loss.
Rich schema: an unknown or future revision is refused, not adopted
The base implementation above binds identity, program root and journal
state, but says nothing about the shape of the state bytes it carries
forward — a migration function could be handed any previous/destination
schema pair with no check that it was ever compiled or checked against that
exact pair. LiveStateMigration::known_schema_transitions closes that gap:
a real migration function declares exactly which
(previous_schema, destination_schema) digest pairs it is checked to
interpret (mirroring execution_revision::typed_migration's own "the
destination must retain the old state schema and provide a second bounded
flat state schema" binding). When it declares a set,
migrate_live_invocation refuses (LiveMigrationError:: UnknownSchemaRevision) any previous/destination interaction_schema_digest
pair outside it, before LiveStateMigration::migrate is ever called —
so an unknown or future schema revision is refused rather than silently
reinterpreted as the destination's schema.
fixture::FixtureSchemaBoundStateMigration is the fixture that declares a
restricted set; migration::tests:: an_unknown_or_future_destination_schema_revision_is_refused_before_the_migration_function_is_ever_called
proves the refusal happens with calls == 0 (never invoked), and
migration::tests::a_migration_bound_to_the_declared_schema_pair_migrates_cleanly_and_records_it
proves the matching known pair still migrates and is recorded on the
handoff (LiveMigrationHandoff::previous_schema_digest/
destination_schema_digest). This is what makes "schema interpretation
remains revision-specific" true at this layer: a migration bound to
(SCHEMA_A, SCHEMA_B) never silently reinterprets state under SCHEMA_C,
no matter how similar the bytes look. FixtureStateMigration and its
siblings keep the prior permissive default (known_schema_transitions
returning None) unchanged, so every pre-existing test in this module is
unaffected by this addition.
Refusal ordering, and what "before any host dispatch" means here
Every LiveMigrationError variant is
checked, in this order, before LiveStateMigration::migrate is ever
called:
PreviousIdentityMismatch— the supplied seed does not derive to the claimed previous identity (a wrong original execution association).StaleDestination— the supplied destination seed does not derive to the claimed destination identity (a stale or reminted destination generation).UnchangedProgramRoot— predecessor and destination name the sameprogram_root: not a version change, and migrating in place would silently approve what is really the same deployment re-runninginitialize.StateCapacity—previous_state(or the migrated result) exceedsMAX_MIGRATED_STATE_BYTES(262 144 bytes, mirroringexecution_revision::typed_migration's own state cap rather than inventing a new number).InvalidPreviousJournal— the previous journal does not causally validate against the claimed previous identity at all.NotTerminal— the previous journal is uncertain, stuck mid-turn (including an "uncertain effect" ending right afterEffectIntentwith noEffectObserved/EffectFailed), or otherwise still in flight. Only a journal that has already reached a recorded terminal outcome is migratable — in-flight or uncertain work must first reach the reviewed suspend/reconciliation state, matching issue #115's bounded scope.NotSuspended— the previous journal is terminal, but its case iscompleteorfail, notsuspend. A completed or failed invocation has nothing left to migrate into a new generation.UnknownSchemaRevision— the boundLiveStateMigrationdeclared a restricted set of known schema pairs, and the exact previous/destination pair is not one of them.MigrationRefused/NonDeterministicMigration— the boundLiveStateMigrationitself refused, or disagreed with itself across its two calls.
Since migrate_live_invocation performs no model, effect, or journal-sink
call of its own — the only injected call it ever makes is to
LiveStateMigration::migrate — every refusal above is structurally "before
any host dispatch or store effect": there is no dispatch inside this
function for a refusal to come after. verify_destination_binding gives
a recovering caller the same zero-cost check independent of a fresh
migration call: it rejects a handoff bound to the wrong destination before
the caller reconstructs a budget ledger, opens the destination's first
turn, or performs any effect of its own.
Idempotent handoffs: what "repeated recovery" means here
LiveMigrationHandoff::digest is a pure function of every field the
handoff carries. migration::tests::migrating_twice_with_identical_inputs_produces_a_byte_identical_handoff
proves that calling migrate_live_invocation twice with byte-identical
inputs produces a digest-identical handoff — the mechanism that keeps a
repeated recovery from ever reading as "a new migration continuation" the
second time. migration::tests::changing_the_migration_function_name_changes_the_handoff_digest
proves the converse: any differing bound input (here, which migration
function was named) changes the digest, so a reminted handoff is
detectable rather than silently accepted as the same one.
Durable destination handoff checkpoint
A successful pure migration is not itself permission to dispatch the
destination. persist_migration_handoff first writes one complete
semaprax.live-invocation.persisted-migration-handoff.v1 document through
the existing caller-owned agent_lifecycle::CheckpointStore contract. The
versioned document binds the destination identity and generation to the
canonical handoff, its digest, the exact migrated-state hex bytes and their
digest, plus the destination journal and its chain digest. Before destination
turn zero, this envelope's defined canonical state is "entries":[] with the
empty journal chain. That is an envelope-only pre-dispatch state: the generic
journal::decode remains unchanged and still rejects an empty standalone
causal journal.
recover_migration_handoff requires the exact destination identity and
recomputes every handoff, state, and journal link before returning a
validated RecoveredMigrationHandoff control record. It refuses a different
or future schema, a substituted destination, a reminted handoff, modified
state bytes, or a changed journal. Zero and exhausted (u64::MAX) generations
are refused, and documents larger than 2,097,152 bytes are rejected before
JSON parsing. Exact canonical re-rendering, including the terminal LF, is
required, so duplicate keys and alternate encodings are not adopted. The
combined sink uses checked generation progression and refuses a would-be
u64::MAX successor before store I/O; every generation it admits is therefore
recoverable for the expected destination, subject to the trusted-store caveat
below.
This record is evidence, not authority. Its hashes prove self-consistency but
not who produced it; RecoveredMigrationHandoff is cloneable and recovery
accepts caller-supplied bytes. Replay resistance and rollback detection depend
on a separately trusted store that reads the latest record and enforces
monotonic generations. CheckpointStore supplies atomic replace-or-retain
writes, but does not by itself supply a read API or promise to reject an older
caller-supplied generation. Journal validation and authorization remain
independent checks.
run_migrated_destination is the migration-specific dispatch route. It
accepts that validated record, checks the destination identity and schema again,
and installs a combined checkpoint sink before it calls the generic kernel.
Every journal write replaces the same document at the next generation while
preserving the handoff and migrated bytes. Therefore the first destination
RequestIntent is durable together with its handoff before model dispatch,
and a recovered terminal destination journal replays with zero new dispatches.
The generic kernel remains an intentionally separate route for fresh,
non-migrated live invocations; it cannot be cited as satisfying this migration
checkpoint requirement. This adapter does not make a caller who bypasses it
safe, and does not grant the recovered record any authority.
migration::tests::a_migration_handoff_checkpoint_recovers_only_when_every_bound_byte_replays
proves the round trip and rejects state, handoff, and schema tampering.
migration::tests::a_persisted_or_recovered_handoff_drives_destination_dispatch_and_replay
proves the real adapter sequence: persist, fail a checkpoint before dispatch,
dispatch through the combined sink, recover, then replay the terminal
destination with zero handler calls. migration::tests::an_accepted_max_minus_one_generation_never_publishes_an_unrecoverable_successor
proves the outbound-generation invariant without touching the store or dispatching.
Non-goals and known limitations (this round)
- No distributed multi-writer transaction, no automatic cross-store
reconciliation. The combined checkpoint uses the existing atomic
replace-or-retain
CheckpointStoreoperation for one caller-selected destination record. It does not coordinate independent stores or mutate the predecessor journal. - No real compiler-checked migration function.
fixture::FixtureStateMigrationand its siblings are deterministic fixtures for exercising the checked-pure double-evaluation boundary; binding a real one against retained HIR is downstream integration work, exactly as this lane already documents forModelHandler/ProposalDecoder/AuthorizationGate. - No live network call, no real provider credential, no model spend.
Every test in
src/live_invocation/migration/tests.rsruns entirely against fixtures. - "Live" here names this kernel's causal-journal contract, not a running
compiled conversation. Live Invocation Contract
v1
records that no parser or HIR syntax for
model.invokeexists yet, andkernel::run_live_invocationis called for real only from this crate's own tests (src/agent_interaction_schema/live_bridge/tests.rs) — nothing outsidesrc/live_invocation/**and its own test tree calls it. This migration module is honestly a persistence/migration layer that is fully exercised and correct against that kernel today; it is not evidence that a source-native Agent conversation can be moved between ProgramRoots yet, because no source-native Agent conversation drives this kernel yet. See "What issue #115 asks for, and why it cannot live inside the kernel" above for why that gap is inherent to the file lease, not an oversight. - Accumulating a whole chain's committed budget is the caller's job.**
LiveMigrationHandoff::previous_committed_budgetreports exactly the immediately preceding generation's total (A's carried total when migrating A→B; B's own total when migrating B→C). A caller assembling one running total across a longer chain sums every hop's own value, the same waymigration::tests::an_a_to_b_to_c_chain_preserves_call_counts_and_never_refunds_committed_budgetdoes; this module does not itself track a chain-wide running total, since it has no persistent state across separate calls to migrate. The migration adapter reborrows the caller's budget hook unchanged; it does not reconstruct or authenticate a chain-wide cumulative ledger. A destination caller must seed that hook/ledger with predecessor totals before dispatch. - This does not close issue #115. The local checkpoint reference now makes its own durable handoff sequence recoverable, but a real compiler-checked HIR migration and trusted cumulative chain accounting are still downstream work. #177's source/HIR live-conversation integration is deliberately outside this module.
- The #228 boundary is unchanged by this module.
migrate_live_invocationperforms no fallible host I/O of its own (no journal-sink write, no model or effect dispatch); it is a pure function over in-memory inputs. The after-dispatch persistence-failure boundary Live Invocation Persistence v1 already names is neither widened nor narrowed here.
Executable reference
cargo test --locked -p semaprax --lib live_invocation
94 tests (the 73 tests Live Invocation Contract v1 and Live Invocation
Persistence v1 already established, unchanged, plus 21 in
migration::tests — the original 16, two rich-schema cases, and three durable
handoff-checkpoint regressions), all fixture-backed, no network access, no model spend.