Skip to main content

Module migration

Module migration 

Source
Expand description

v1 → v2 community migration — the atomic dissolution-carrier wire codec (task #10).

The migration rides INSIDE the vsk=10 GroupDissolved tombstone’s content: a signpost (where the v2 twin lives) plus m, the complete v2 JoinMaterial sealed under the v1 server root at publish time. One owner-signed event seals v1, signposts v2, and IS every member’s invite. Shipped v0.4.0 never parses tombstone content (validity is vsk + coordinate + signer), so old clients fold this as a plain dissolution — composer lockdown, history intact — and the relays retain the event for any later re-probe.

Scope discipline: the signpost is readable by anyone who can open the tombstone’s id-derived envelope (it grants nothing); m opens only under a held server-root epoch key — exactly v1’s confidentiality boundary, so a read-cut member cannot open it. Keys are NEVER placed under the id-derived envelope: the community id rides in every invite bundle ever shared and is not a secret.

Structs§

MigrationPayload
The parsed migration payload: signpost + optionally the sealed key material.
MigrationSignpost
The plaintext signpost: where the v2 twin lives. Grants nothing by itself — every field is verified against the member’s held v1 owner anchor (triple-bind) before any use.

Constants§

MAX_M_B64
Bound on the base64 m string inside the payload (checked before decode/open).
MAX_PAYLOAD_CONTENT
Bound on the whole tombstone content string before any parse. The outer NIP-44 seal caps its plaintext at 65535 bytes, so anything larger is garbage by construction.
MAX_SIGNPOST_NAME
Display-name cap in the signpost — truncated, not rejected (fail-safe parse).
MAX_WIRE_EVENT
Conservative relay max-event-size floor (strfry default is 64 KB): the final sealed OUTER event’s JSON must stay under this or common relays will reject the publish.
MIGRATION_UNLOCK_AT
When the owner-side migration wizard unlocks: 2026-08-04 00:00:00 UTC. Gates ONLY the wizard (UI row + command entry); the member-side machinery is live from release day, so a migration performed by a lock-bypassing build still carries every member along. A coordination gate, not a security gate.
PHASE_CARRIER_PUBLISHED
PHASE_FLIPPED
PHASE_TWIN_BUILT
PHASE_TWIN_MINTED
Ledger phases (persisted in community_migrations.phase). Each is idempotent + resumable. TWIN_MINTED lands IMMEDIATELY after the twin’s genesis returns, BEFORE the sibling-channel and banlist tail — so a crash anywhere in that multi-await tail resumes onto the SAME twin instead of re-minting a fresh identity (which would orphan the first genesis on the relays). Residual window: a crash INSIDE create_migration_twin (after its local save, before return — spanning its genesis + guestbook publishes, so seconds over a slow transport) still re-mints on the next run and leaves a phantom v2 row — bounded: no carrier ever references it, so no member is ever stranded; it is a dead genesis plus one stale local row.
PHASE_TWIN_REFOUNDED
Birth refound 0→1 + Guestbook snapshot of the full v1 roster landed. AFTER this the twin is at epoch 1, so m (sealed in the next phase) carries the epoch-1 root — the whole reason for the refound (genesis has no snapshot authority).

Functions§

build_migration_content
Serialize a payload for the tombstone content (the wizard’s side of parse_migration_payload).
catchup_exempt
The dissolved-gate exemption: a base rekey may advance a SEALED community’s epoch only while a migration pointer is held, the flip hasn’t happened, and the target epoch does not exceed the pointer’s publish epoch. Lets a stale member walk their held root forward to the one m was sealed under, without ever re-opening the seal for anything else. Any read error fails closed (no exemption).
check_outer_size
Wire-size gate for the final sealed OUTER event — computed on the actual bytes, never estimated. Run by the wizard before publishing; failing is a clean abort.
clear_drive_inflight
Clear the drive-in-flight set (account swap — the new account’s drives must not be blocked by a stale claim from the old one).
drive_migration
Drive a held v1 community’s migration to completion when a pointer is present and the flip hasn’t happened: open m (catching a stale root up first, no-erase), join the v2 twin through the ban-gated accept path, then run the flip transaction. Idempotent and resumable — safe to call from the boot sweep, the live fold, and the fallback door. Returns Ok(Some(v2_id)) on a completed flip, Ok(None) when nothing was actionable (no pointer, already flipped, or m unopenable — the straggler CTA case).
gate_fresh_v1_join
The post-timelock door for FRESH v1 joins, probe-first. Pre-unlock, or a community we already hold (re-accept / cross-device rehydrate), passes locally. Post-unlock a fresh join passes ONLY when the rotation-stable dissolved coordinate carries the proven owner’s migration pointer — that join is the permanent on-ramp (save → carrier fold seals → drive lands the joiner in the v2 twin). A live v1 community, an unprovable owner, or a relay miss all refuse: fail-closed, a retry beats onboarding a fresh user onto the legacy protocol. Every v1 join door (Tauri direct + public accepts, facade direct + public accepts) must call this before persisting anything.
migrate_community_to_v2
migration_eligible
Whether the wizard may run for this community (the row’s action is armed). A sealed community is eligible ONLY as a resume of its own in-flight migration.
migration_state
The UI state ladder for a v1 community’s migration row, in priority order. Pure so the ordering is testable: in_progress MUST outrank dissolved, because the owner’s own carrier self-fold seals v1 while the flip is still pending — exactly the window the Resume affordance exists for. A ledger row only ever exists on the wizard’s own account.
open_m
Try to open m under EVERY held server-root epoch key, newest first — absorbs both a stale local head and a concurrent v1 refound that advanced past the publish root. None = no held root opens it (caller decides: catch-up walk, then the straggler CTA).
parse_migration_payload
Parse a tombstone’s content into a migration payload. Fail-SAFE by contract: any bound violation, shape error, or bad field returns None — the event remains a plain dissolution and the SEAL is never rejected. Ids are lowercase-normalized; relays are capped by truncation (hostile payloads degrade, never amplify); the name is truncated.
run_migration_maintenance
Boot / account-swap maintenance: (1) re-drive every held pointer whose flip hasn’t landed (crash recovery, stale-root retry, unopenable-m retry — drive_migration is idempotent and its held-v2 dedup makes the “joined but never flipped” crash a pure flip on re-run); (2) probe every sealed pointer-less community for a payload the client missed (the upgrade-lag sweep). Call at boot and after an account swap.
seal_m
Seal the v2 JoinMaterial under the v1 server root at publish time. Errors past NIP-44’s 65535-byte plaintext cap — the wizard surfaces that as a clean abort, never a truncation.
select_pointer
Total, payload-aware pointer selection over the owner’s tombstones: the pointer comes from the newest PAYLOAD-CARRYING owner-signed tombstone (tiebreak lowest inner id); a payload-less {} tombstone SEALS the community but never shadows a payload-bearing one — otherwise a plain dissolution from a second device would silently shed the keys for every future straggler. (Corollary, deliberate: a NEWER payload-bearing tombstone WITHOUT m is the owner’s honest-client-scoped retraction lever for the on-ramp.)
spawn_finalize_migration
Post-flip finalize: stamp the stitched chats as the v2 community (name/metadata, proto_version → 2 monotonic, dissolved=false — the ROOM is alive on v2 even though the v1 row is sealed) and tell the UI. Spawned (SessionGuard captured BEFORE the spawn, per the multi-account contract) so no caller’s lock context can deadlock the STATE lock.
sweep_dissolved_for_migration
Boot / account-swap sweep: for every pointer-less, unchecked v1 community — sealed or not — re-probe the rotation-stable dissolved coordinate. Extract + persist any migration payload and drive the flip; a plain {} dissolution is marked checked so it is never re-probed.
wizard_unlocked
Whether the owner wizard is unlocked (the timelock is a coordination gate, re-checked at the command entry, not just in the UI). now_secs is passed in (the core has no clock).