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§
- Migration
Payload - The parsed migration payload: signpost + optionally the sealed key material.
- Migration
Signpost - 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
mstring 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
mwas 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. ReturnsOk(Some(v2_id))on a completed flip,Ok(None)when nothing was actionable (no pointer, already flipped, ormunopenable — 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_progressMUST outrankdissolved, 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
munder 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-
mretry —drive_migrationis 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 WITHOUTmis 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 SEALED, pointer-less-checked v1 community, re-probe
the rotation-stable dissolved coordinate (relays retain the event; the client kept only
the
dissolvedflag). Extract + persist any migration payload and drive the flip; a plain{}dissolution is marked checked so it is never re-probed. This is the recovery path for a member who folded the tombstone on a build that predated migration support. - 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_secsis passed in (the core has no clock).