Skip to main content

vector_core/community/
migration.rs

1//! v1 → v2 community migration — the atomic dissolution-carrier wire codec (task #10).
2//!
3//! The migration rides INSIDE the vsk=10 GroupDissolved tombstone's content: a signpost
4//! (where the v2 twin lives) plus `m`, the complete v2 JoinMaterial sealed under the v1
5//! server root at publish time. One owner-signed event seals v1, signposts v2, and IS
6//! every member's invite. Shipped v0.4.0 never parses tombstone content (validity is
7//! vsk + coordinate + signer), so old clients fold this as a plain dissolution — composer
8//! lockdown, history intact — and the relays retain the event for any later re-probe.
9//!
10//! Scope discipline: the signpost is readable by anyone who can open the tombstone's
11//! id-derived envelope (it grants nothing); `m` opens only under a held server-root epoch
12//! key — exactly v1's confidentiality boundary, so a read-cut member cannot open it. Keys
13//! are NEVER placed under the id-derived envelope: the community id rides in every invite
14//! bundle ever shared and is not a secret.
15
16use super::cipher;
17use super::roster::DissolvedEdition;
18use super::transport::Transport;
19use super::{Community, CommunityId};
20use serde::{Deserialize, Serialize};
21use std::collections::HashSet;
22use std::sync::{LazyLock, Mutex as StdMutex};
23
24/// v1 cids with a `drive_migration` in flight — mutual exclusion so the boot maintenance
25/// and the live carrier-fold never drive the SAME community concurrently. Without it two
26/// drives can interleave a channel-less `save_community_v2` (which prunes) after the other's
27/// re-parent commit, deleting a just-adopted row. Cleared on account swap.
28static DRIVE_INFLIGHT: LazyLock<StdMutex<HashSet<String>>> =
29    LazyLock::new(|| StdMutex::new(HashSet::new()));
30
31/// Clear the drive-in-flight set (account swap — the new account's drives must not be
32/// blocked by a stale claim from the old one).
33pub fn clear_drive_inflight() {
34    DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).clear();
35}
36
37/// RAII claim on [`DRIVE_INFLIGHT`]. `take` returns `None` when the cid is already claimed
38/// (drive vs drive, wizard vs drive, wizard vs wizard). Drop is generation-aware: after an
39/// account swap clears the set, a stale claim's unwind must not release the claim the NEW
40/// account just inserted for the same cid.
41struct DriveClaim(String, std::sync::Arc<crate::db::Session>);
42impl DriveClaim {
43    fn take(cid: &str) -> Option<Self> {
44        let mut inflight = DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
45        if !inflight.insert(cid.to_string()) {
46            return None;
47        }
48        Some(DriveClaim(cid.to_string(), crate::db::current_session()))
49    }
50}
51impl Drop for DriveClaim {
52    fn drop(&mut self) {
53        if self.1.is_live() {
54            DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
55        }
56    }
57}
58
59/// Test hooks: stand in for a concurrent drive holding the claim (the claim is private and
60/// RAII-scoped, so a test can't otherwise model "another drive is mid-flight").
61#[cfg(test)]
62pub fn test_hold_drive_claim(cid: &str) {
63    DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).insert(cid.to_string());
64}
65#[cfg(test)]
66pub fn test_release_drive_claim(cid: &str) {
67    DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).remove(cid);
68}
69
70/// When the owner-side migration wizard unlocks: 2026-08-04 00:00:00 UTC. Gates ONLY the
71/// wizard (UI row + command entry); the member-side machinery is live from release day, so
72/// a migration performed by a lock-bypassing build still carries every member along. A
73/// coordination gate, not a security gate.
74pub const MIGRATION_UNLOCK_AT: u64 = 1_785_801_600;
75
76/// Bound on the whole tombstone content string before any parse. The outer NIP-44 seal
77/// caps its plaintext at 65535 bytes, so anything larger is garbage by construction.
78pub const MAX_PAYLOAD_CONTENT: usize = 100_000;
79/// Bound on the base64 `m` string inside the payload (checked before decode/open).
80pub const MAX_M_B64: usize = 90_000;
81/// Display-name cap in the signpost — truncated, not rejected (fail-safe parse).
82pub const MAX_SIGNPOST_NAME: usize = 120;
83/// Conservative relay max-event-size floor (strfry default is 64 KB): the final sealed
84/// OUTER event's JSON must stay under this or common relays will reject the publish.
85pub const MAX_WIRE_EVENT: usize = 60_000;
86
87/// The plaintext signpost: where the v2 twin lives. Grants nothing by itself — every field
88/// is verified against the member's held v1 owner anchor (triple-bind) before any use.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct MigrationSignpost {
91    /// The v2 self-certifying community id. Must recompute from `owner_xonly` + `owner_salt`.
92    pub v2_community_id: String,
93    /// Must equal the member's held v1 owner anchor (owner continuity — the consent basis).
94    pub owner_xonly: String,
95    /// The v2 owner salt (public input to the self-cert id).
96    pub owner_salt: String,
97    /// The v2 relay set (capped like every other attacker-influencable relay list).
98    /// Absent is empty, not a parse failure — a rejected signpost strands the migration.
99    #[serde(default)]
100    pub relays: Vec<String>,
101    /// Display name at migration time (informational only).
102    pub name: String,
103    /// Which stitched channel the one-row UI surfaces.
104    pub primary_channel: String,
105    /// The v1 base epoch at publish — bounds a stale member's catch-up walk before they
106    /// conclude `m` is unopenable (a member holding an epoch >= this that still cannot
107    /// open `m` is genuinely outside the member set).
108    #[serde(default)]
109    pub root_epoch: u64,
110}
111
112/// The parsed migration payload: signpost + optionally the sealed key material.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct MigrationPayload {
115    pub signpost: MigrationSignpost,
116    /// base64 NIP-44 seal of the v2 JoinMaterial under the v1 server_root at publish.
117    /// `None` = signpost-only (still a valid pointer; the member falls back to the
118    /// straggler CTA if no key material ever opens).
119    pub m: Option<String>,
120}
121
122/// Wire shape of the tombstone content. `migrated_to` keys the signpost so a plain `{}`
123/// (the pre-migration dissolution) deserializes to "no payload" rather than erroring.
124#[derive(Serialize, Deserialize)]
125struct WirePayload {
126    migrated_to: MigrationSignpost,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    m: Option<String>,
129}
130
131fn is_hex64(s: &str) -> bool {
132    s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
133}
134
135/// Parse a tombstone's content into a migration payload. Fail-SAFE by contract: any bound
136/// violation, shape error, or bad field returns `None` — the event remains a plain
137/// dissolution and the SEAL is never rejected. Ids are lowercase-normalized; relays are
138/// capped by truncation (hostile payloads degrade, never amplify); the name is truncated.
139pub fn parse_migration_payload(content: &str) -> Option<MigrationPayload> {
140    if content.len() > MAX_PAYLOAD_CONTENT {
141        return None;
142    }
143    let wire: WirePayload = serde_json::from_str(content).ok()?;
144    let mut sp = wire.migrated_to;
145    if !is_hex64(&sp.v2_community_id)
146        || !is_hex64(&sp.owner_xonly)
147        || !is_hex64(&sp.owner_salt)
148        || !is_hex64(&sp.primary_channel)
149    {
150        return None;
151    }
152    sp.v2_community_id = sp.v2_community_id.to_lowercase();
153    sp.owner_xonly = sp.owner_xonly.to_lowercase();
154    sp.owner_salt = sp.owner_salt.to_lowercase();
155    sp.primary_channel = sp.primary_channel.to_lowercase();
156    sp.relays = super::cap_relays(sp.relays);
157    if sp.name.chars().count() > MAX_SIGNPOST_NAME {
158        sp.name = sp.name.chars().take(MAX_SIGNPOST_NAME).collect();
159    }
160    let m = match wire.m {
161        Some(m) if m.len() > MAX_M_B64 => return None,
162        other => other,
163    };
164    Some(MigrationPayload { signpost: sp, m })
165}
166
167/// Serialize a payload for the tombstone content (the wizard's side of [`parse_migration_payload`]).
168pub fn build_migration_content(signpost: &MigrationSignpost, m: Option<String>) -> Result<String, String> {
169    serde_json::to_string(&WirePayload { migrated_to: signpost.clone(), m })
170        .map_err(|e| format!("serialize migration payload: {e}"))
171}
172
173/// Seal the v2 JoinMaterial under the v1 server root at publish time. Errors past NIP-44's
174/// 65535-byte plaintext cap — the wizard surfaces that as a clean abort, never a truncation.
175pub fn seal_m(server_root: &[u8; 32], join_material_json: &[u8]) -> Result<String, String> {
176    cipher::seal(server_root, join_material_json)
177}
178
179/// Try to open `m` under EVERY held server-root epoch key, newest first — absorbs both a
180/// stale local head and a concurrent v1 refound that advanced past the publish root.
181/// `None` = no held root opens it (caller decides: catch-up walk, then the straggler CTA).
182pub fn open_m(held_roots: &[(u64, [u8; 32])], m_b64: &str) -> Option<Vec<u8>> {
183    if m_b64.len() > MAX_M_B64 {
184        return None;
185    }
186    let mut roots: Vec<&(u64, [u8; 32])> = held_roots.iter().collect();
187    roots.sort_by_key(|r| std::cmp::Reverse(r.0));
188    for (_, key) in roots {
189        if let Ok(plain) = cipher::open(key, m_b64) {
190            return Some(plain);
191        }
192    }
193    None
194}
195
196/// Total, payload-aware pointer selection over the owner's tombstones: the pointer comes
197/// from the newest PAYLOAD-CARRYING owner-signed tombstone (tiebreak lowest inner id); a
198/// payload-less `{}` tombstone SEALS the community but never shadows a payload-bearing one
199/// — otherwise a plain dissolution from a second device would silently shed the keys for
200/// every future straggler. (Corollary, deliberate: a NEWER payload-bearing tombstone
201/// WITHOUT `m` is the owner's honest-client-scoped retraction lever for the on-ramp.)
202pub fn select_pointer(editions: &[DissolvedEdition], owner_hex: &str) -> Option<(MigrationPayload, String)> {
203    let mut best: Option<(&DissolvedEdition, MigrationPayload)> = None;
204    for e in editions {
205        if e.author.to_hex() != owner_hex {
206            continue;
207        }
208        let Some(payload) = parse_migration_payload(&e.content) else { continue };
209        best = match best {
210            Some((cur, cur_p))
211                if (cur.created_at, std::cmp::Reverse(cur.inner_id))
212                    >= (e.created_at, std::cmp::Reverse(e.inner_id)) =>
213            {
214                Some((cur, cur_p))
215            }
216            _ => Some((e, payload)),
217        };
218    }
219    // The raw winning content rides along so the caller can persist the exact wire form
220    // (re-parseable later without a second serializer for the payload).
221    best.map(|(e, p)| (p, e.content.clone()))
222}
223
224/// Wire-size gate for the final sealed OUTER event — computed on the actual bytes, never
225/// estimated. Run by the wizard before publishing; failing is a clean abort.
226pub fn check_outer_size(outer: &nostr_sdk::prelude::Event) -> Result<(), String> {
227    let len = outer.as_json().len();
228    if len > MAX_WIRE_EVENT {
229        return Err(format!(
230            "migration event is {len} bytes, over the {MAX_WIRE_EVENT}-byte relay ceiling; \
231             this community is too large for a single migration event"
232        ));
233    }
234    Ok(())
235}
236
237// ── Member flow: fold a migration pointer → open `m` → flip to v2 ─────────────────────────
238
239/// The dissolved-gate exemption: a base rekey may advance a SEALED community's epoch
240/// only while a migration pointer is held, the flip hasn't happened, and the target epoch
241/// does not exceed the pointer's publish epoch. Lets a stale member walk their held root
242/// forward to the one `m` was sealed under, without ever re-opening the seal for anything
243/// else. Any read error fails closed (no exemption).
244pub fn catchup_exempt(community_id: &str, target_epoch: u64) -> bool {
245    if crate::db::community::get_migrated_to(community_id).ok().flatten().is_some() {
246        return false; // already flipped — the fence stands
247    }
248    let Ok(Some(raw)) = crate::db::community::get_migration_pointer(community_id) else {
249        return false; // no pointer → a plain dissolution, never advances
250    };
251    let Some(payload) = parse_migration_payload(&raw) else { return false };
252    target_epoch <= payload.signpost.root_epoch
253}
254
255/// Held server-root epoch keys for a community, for the multi-root `m` open.
256fn held_roots(community_id: &str) -> Vec<(u64, [u8; 32])> {
257    crate::db::community::held_epoch_keys(community_id, crate::community::SERVER_ROOT_SCOPE_HEX)
258        .unwrap_or_default()
259        .into_iter()
260        .map(|(e, k)| (e.0, k))
261        .collect()
262}
263
264/// Drive a held v1 community's migration to completion when a pointer is present and the
265/// flip hasn't happened: open `m` (catching a stale root up first, no-erase), join the v2
266/// twin through the ban-gated accept path, then run the flip transaction. Idempotent and
267/// resumable — safe to call from the boot sweep, the live fold, and the fallback door.
268/// Returns `Ok(Some(v2_id))` on a completed flip, `Ok(None)` when nothing was actionable
269/// (no pointer, already flipped, or `m` unopenable — the straggler CTA case).
270pub async fn drive_migration<T: Transport + ?Sized>(
271    transport: &T,
272    community: &Community,
273) -> Result<Option<String>, String> {
274    let session = crate::db::current_session();
275    let cid = community.id.to_hex();
276
277    // Claim the in-flight slot or bail — a concurrent drive (boot maintenance vs the live
278    // carrier fold, or the owner's wizard vs their own fold) is already handling this cid.
279    // Without this, two channel-less v2 saves can interleave a prune after the other's
280    // re-parent. The claim is released on ANY exit (RAII).
281    let Some(_claim) = DriveClaim::take(&cid) else {
282        crate::log_debug!("[migration] {cid}: another drive is in flight");
283        return Ok(None);
284    };
285
286    // Already flipped? Nothing to do (idempotent double-trigger).
287    if crate::db::community::get_migrated_to(&cid).ok().flatten().is_some() {
288        return Ok(None);
289    }
290    let Some(raw) = crate::db::community::get_migration_pointer(&cid)? else {
291        return Ok(None); // no pointer → not a migration
292    };
293    let Some(payload) = parse_migration_payload(&raw) else {
294        // A stored-but-unparseable pointer is inert; mark checked so the sweep converges.
295        crate::log_debug!("[migration] {cid}: pointer unparseable, marked checked");
296        let _ = crate::db::community::set_migration_checked(&cid);
297        return Ok(None);
298    };
299
300    // Triple-bind check 2 — OWNER CONTINUITY: the v2 owner the signpost claims MUST equal this
301    // v1 community's proven owner (the pointer is already owner-signed at the bound coordinate,
302    // check 1). A migration that changes the owner identity is NOT eligible for consent-free
303    // join — identity continuity is the whole basis for skipping consent. Fail-closed: no
304    // proven owner ⇒ no continuity ⇒ no auto-join. (check 3 — the v2 self-cert recompute — runs
305    // inside accept_bundle.)
306    let Some(owner) = super::service::proven_owner_hex(community) else {
307        crate::log_debug!("[migration] {cid}: no proven owner, cannot verify continuity");
308        return Ok(None);
309    };
310    if payload.signpost.owner_xonly != owner {
311        // Owner discontinuity → not a consent-free migration.
312        crate::log_info!("[migration] {cid}: signpost names a different owner; not followed automatically");
313        return Ok(None);
314    }
315    // Bind the v2 self-cert id to the signpost's own owner/salt before the network join.
316    if !super::v2::derive::verify_community_id(
317        &CommunityId(crate::simd::hex::hex_to_bytes_32(&payload.signpost.v2_community_id)),
318        &crate::simd::hex::hex_to_bytes_32(&payload.signpost.owner_xonly),
319        &crate::simd::hex::hex_to_bytes_32(&payload.signpost.owner_salt),
320    ) {
321        crate::log_warn!("[migration] {cid}: signpost v2 id is not a commitment to its owner+salt; ignored");
322        return Ok(None);
323    }
324
325    let v2_id = CommunityId(crate::simd::hex::hex_to_bytes_32(&payload.signpost.v2_community_id));
326    let v2_hex = payload.signpost.v2_community_id.clone();
327
328    // Held-twin shortcut, BEFORE `m` is needed. `m` only carries join material; an account
329    // that already holds the twin (list adoption, an ordinary invite, another device) has
330    // nothing to open, and an unopenable `m` must not strand a flip that needs no keys.
331    // The twin's id is a self-cert of the signpost's owner+salt (verified above), so the
332    // held row's owner is re-checked only as a corruption guard.
333    if let Some(held) = crate::db::community::load_community_v2(&v2_id)? {
334        if held.owner()?.to_hex() != payload.signpost.owner_xonly {
335            crate::log_warn!("[migration] {cid}: held twin {} has a different owner than the signpost; refusing", &v2_hex[..8]);
336            return Ok(None);
337        }
338        return flip_to_twin(&session, &cid, &v2_id, &v2_hex).await.map(Some);
339    }
340
341    let Some(m_b64) = payload.m.as_deref() else {
342        // Signpost-only pointer: no keys to open, and the twin is not held. Loud on
343        // purpose: this state is invisible otherwise, and only a join to the twin ends it.
344        crate::log_warn!("[migration] {cid}: flip pending, twin {} not held and the signpost carries no keys; joining the twin lets the next drive flip", &v2_hex[..8]);
345        return Ok(None);
346    };
347
348    // Open `m` under any held root; if none opens and we're stale vs the publish epoch,
349    // walk the base root forward (no-erase: the `removed` signal is ignored — the community
350    // is terminal either way) and retry. The exemption above lets this walk run despite the
351    // seal.
352    let mut plain = open_m(&held_roots(&cid), m_b64);
353    if plain.is_none() && community.server_root_epoch.0 < payload.signpost.root_epoch {
354        let _ = super::service::catch_up_server_root(transport, community).await;
355        if !session.is_live() {
356            return Err("account changed during migration catch-up".to_string());
357        }
358        plain = open_m(&held_roots(&cid), m_b64);
359    }
360    let Some(plain) = plain else {
361        // Unopenable (read-cut or lost DB) and the twin is not held. Loud on purpose:
362        // this state is invisible otherwise, and only a join to the twin ends it.
363        crate::log_warn!("[migration] {cid}: flip pending, twin {} not held and the join material does not open under any held root; joining the twin lets the next drive flip", &v2_hex[..8]);
364        return Ok(None);
365    };
366
367    // Verify the JoinMaterial's owner matches the pointer's owner continuity claim before
368    // trusting it (the self-cert id recompute is the fail-closed gate inside accept_bundle).
369    let jm: super::v2::list::JoinMaterial =
370        serde_json::from_slice(&plain).map_err(|e| format!("migration join material parse: {e}"))?;
371    if jm.community_id != payload.signpost.v2_community_id || jm.owner != payload.signpost.owner_xonly {
372        return Err("migration payload keys disagree with the signpost".to_string());
373    }
374
375    // Not held (the shortcut above returned otherwise): join the v2 twin (ban-gated,
376    // owner-root-verified). A refusal (banned / forged root) leaves the community sealed —
377    // never a half-flip.
378    let v2 = super::v2::service::accept_migration_material(transport, &jm).await?;
379    if crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0) != v2_hex {
380        return Err("joined community id disagrees with the migration pointer".to_string());
381    }
382    if !session.is_live() {
383        return Err("account changed during migration join".to_string());
384    }
385    flip_to_twin(&session, &cid, &v2_id, &v2_hex).await.map(Some)
386}
387
388/// The flip: re-parent the stitched channel rows + stamp the fence (one txn). The v2
389/// community ROW already exists (accept saved it, or this account already held it); NO
390/// save_community_v2 here — the v2 view is channel-less (its channel ids were v1-owned →
391/// skipped by the hijack guard), so a re-save would PRUNE the just-re-parented rows.
392/// Public channels fold from the control plane; the re-parented rows carry the history.
393/// Under the twin's follow lock: the follow worker's whole-row save deletes channel rows
394/// absent from a pre-flip-loaded (channel-less) struct, so the flip must not straddle it.
395/// The session check sits immediately before the most destructive write in the feature.
396async fn flip_to_twin(
397    session: &std::sync::Arc<crate::db::Session>,
398    v1_cid: &str,
399    v2_id: &CommunityId,
400    v2_hex: &str,
401) -> Result<String, String> {
402    let flock = super::v2::realtime::follow_lock(v2_id);
403    let _fguard = flock.lock().await;
404    if !session.is_live() {
405        return Err("account changed during migration flip".to_string());
406    }
407    crate::db::community::reparent_channels_and_fence(v1_cid, v2_hex)?;
408    Ok(v2_hex.to_string())
409}
410
411// ── Owner flow: the migration wizard ─────────────────────────────────────────────────────
412
413/// Ledger phases (persisted in `community_migrations.phase`). Each is idempotent + resumable.
414/// TWIN_MINTED lands IMMEDIATELY after the twin's genesis returns, BEFORE the sibling-channel
415/// and banlist tail — so a crash anywhere in that multi-await tail resumes onto the SAME twin
416/// instead of re-minting a fresh identity (which would orphan the first genesis on the relays).
417/// Residual window: a crash INSIDE create_migration_twin (after its local save, before return
418/// — spanning its genesis + guestbook publishes, so seconds over a slow transport) still
419/// re-mints on the next run and leaves a phantom v2 row — bounded: no carrier ever references
420/// it, so no member is ever stranded; it is a dead genesis plus one stale local row.
421pub const PHASE_TWIN_MINTED: i64 = 1;
422pub const PHASE_TWIN_BUILT: i64 = 2;
423/// Birth refound 0→1 + Guestbook snapshot of the full v1 roster landed. AFTER this the twin
424/// is at epoch 1, so `m` (sealed in the next phase) carries the epoch-1 root — the whole
425/// reason for the refound (genesis has no snapshot authority).
426pub const PHASE_TWIN_REFOUNDED: i64 = 3;
427pub const PHASE_CARRIER_PUBLISHED: i64 = 4;
428pub const PHASE_FLIPPED: i64 = 5;
429
430/// The full v1 memberlist to seed into the twin's epoch-1 Guestbook. `community_member_activity`
431/// IS v1's single source of truth for "who is a member": it already unions observed authors +
432/// the owner + every roster grant-holder, subtracts the banlist, and applies the leave filter
433/// — so the seed is exactly that set (fetched UNCAPPED, so a large community seeds every
434/// member instead of the top 500-by-recency). Using it verbatim means the v2 seed can NEVER
435/// diverge from what v1 itself shows as members.
436///
437/// note: a v1 admin who LEFT but was never stripped of their grant is re-asserted as a
438/// member by that function (v1's rule: a leave must not lock a sitting admin out) — so they
439/// ARE seeded. That is v1-faithful (v1 shows them as a member), a deliberate divergence from
440/// the design's "− left" line for the admin case; a NON-admin who left is dropped by the leave
441/// filter. `owner_hex` is unused now (the DB seeds the owner) but kept for call-site clarity.
442fn v1_snapshot_members(v1_cid: &str, _owner_hex: &str) -> Vec<nostr_sdk::prelude::PublicKey> {
443    crate::db::community::community_member_activity_capped(v1_cid, false)
444        .unwrap_or_default()
445        .iter()
446        .filter_map(|(npub, _)| nostr_sdk::prelude::PublicKey::parse(npub).ok())
447        .collect()
448}
449
450/// Whether the owner wizard is unlocked (the timelock is a coordination gate, re-checked at
451/// the command entry, not just in the UI). `now_secs` is passed in (the core has no clock).
452pub fn wizard_unlocked(now_secs: u64) -> bool {
453    now_secs >= MIGRATION_UNLOCK_AT
454}
455
456/// The post-timelock door for FRESH v1 joins, probe-first. Pre-unlock, or a community we
457/// already hold (re-accept / cross-device rehydrate), passes locally. Post-unlock a fresh
458/// join passes ONLY when the rotation-stable dissolved coordinate carries the proven
459/// owner's migration pointer — that join is the permanent on-ramp (save → carrier fold
460/// seals → drive lands the joiner in the v2 twin). A live v1 community, an unprovable
461/// owner, or a relay miss all refuse: fail-closed, a retry beats onboarding a fresh user
462/// onto the legacy protocol. Every v1 join door (Tauri direct + public accepts, facade
463/// direct + public accepts) must call this before persisting anything.
464pub async fn gate_fresh_v1_join<T: Transport + ?Sized>(
465    transport: &T,
466    community: &Community,
467    now_secs: u64,
468) -> Result<(), String> {
469    if now_secs < MIGRATION_UNLOCK_AT {
470        return Ok(());
471    }
472    if matches!(crate::db::community::load_community(&community.id), Ok(Some(_))) {
473        return Ok(());
474    }
475    if let Some(owner) = super::service::proven_owner_hex(community) {
476        let records = super::service::dissolved_tombstone_records(transport, community).await;
477        if select_pointer(&records, &owner).is_some() {
478            return Ok(());
479        }
480    }
481    Err("This community still uses the legacy protocol and can no longer be joined. Ask the owner to upgrade it to Concord v2 and share a fresh invite.".to_string())
482}
483
484/// The UI state ladder for a v1 community's migration row, in priority order. Pure so the
485/// ordering is testable: `in_progress` MUST outrank `dissolved`, because the owner's own
486/// carrier self-fold seals v1 while the flip is still pending — exactly the window the
487/// Resume affordance exists for. A ledger row only ever exists on the wizard's own account.
488pub fn migration_state(
489    migrated: bool,
490    ledger_phase: i64,
491    dissolved: bool,
492    is_owner: bool,
493    unlocked: bool,
494) -> &'static str {
495    if migrated {
496        "migrated"
497    } else if ledger_phase > 0 && is_owner {
498        "in_progress"
499    } else if dissolved {
500        "dissolved"
501    } else if !is_owner {
502        "not_owner"
503    } else if unlocked {
504        "ready"
505    } else {
506        "locked"
507    }
508}
509
510/// Whether the wizard may run for this community (the row's action is armed). A sealed
511/// community is eligible ONLY as a resume of its own in-flight migration.
512pub fn migration_eligible(migrated: bool, ledger_phase: i64, dissolved: bool, is_owner: bool) -> bool {
513    is_owner && !migrated && (!dissolved || ledger_phase > 0)
514}
515
516/// Owner-side migration wizard: build the v2 twin (reusing v1 channel ids + cloning the
517/// banlist), seal the twin's JoinMaterial into `m`, publish the carrier dissolution on v1,
518/// then flip the owner's own client to v2. Resumable via the migration-77 ledger — a re-run
519/// after a crash picks up at the recorded phase. Every phase re-checks the `std::sync::Arc<crate::db::Session>`.
520/// `now_secs` gates the timelock. Returns the v2 community id on completion.
521/// Emit a wizard progress step to the UI (no-op on headless clients via the unregistered
522/// emitter). `pct` is OVERALL progress 0-100 across the whole wizard; `label` is layman-facing.
523/// The frontend renders a determinate ring + this label in an unclosable modal (rekey contract).
524fn emit_migration_progress(label: &str, pct: u8) {
525    crate::emit_event("community_migration_progress", &serde_json::json!({ "label": label, "pct": pct }));
526}
527
528pub async fn migrate_community_to_v2<T: Transport + ?Sized>(
529    transport: &T,
530    v1: &Community,
531    now_secs: u64,
532) -> Result<String, String> {
533    let session = crate::db::current_session();
534    let v1_cid = v1.id.to_hex();
535    // One wizard/drive at a time per cid: a double-fired command would race the twin mint
536    // pre-ledger (the double-mint orphan window) and its flip against a concurrent drive's.
537    let Some(_claim) = DriveClaim::take(&v1_cid) else {
538        return Err("this community's upgrade is already in progress".to_string());
539    };
540    emit_migration_progress("Preparing the upgrade...", 5);
541
542    // Phase 0 — preflight (owner, unlocked, not already dissolved/migrated).
543    if !wizard_unlocked(now_secs) {
544        return Err("community migration is not unlocked yet".to_string());
545    }
546    if !super::service::is_proven_owner(v1) {
547        return Err("only the community owner can migrate the community".to_string());
548    }
549    // Already migrated? Check FIRST — a migrated community is ALSO dissolved (the flip seals
550    // it), so this must precede the dissolved gate below or a legitimate crash-heal reads as
551    // a plain dissolution.
552    if let Some(v2) = crate::db::community::get_migrated_to(&v1_cid).ok().flatten() {
553        // Crash-heal: the flip LANDED but the FLIPPED ledger write didn't (a crash between
554        // the two). This re-run is a COMPLETED migration — heal the ledger and report
555        // success instead of erroring on our own success.
556        if let Some((ledger_v2, phase, _)) = crate::db::community::get_migration_ledger(&v1_cid).ok().flatten() {
557            if ledger_v2 == v2 && phase < PHASE_FLIPPED {
558                let _ = crate::db::community::set_migration_ledger(&v1_cid, &v2, PHASE_FLIPPED, "");
559                return Ok(v2);
560            }
561        }
562        return Err("this community has already been migrated".to_string());
563    }
564    // Resume ledger read up-front — a ledger row means THIS owner already began THIS
565    // migration, which the dissolved gate below keys off.
566    let ledger = crate::db::community::get_migration_ledger(&v1_cid).ok().flatten();
567    let resume_phase = ledger.as_ref().map(|(_, p, _)| *p).unwrap_or(0);
568
569    // Not already dissolved: a carrier published for a sealed-but-UNMIGRATED
570    // community is undeliverable — members' folds short-circuit on the seal and never see
571    // it, so the owner would flip alone. A plain dissolution and a migration are mutually
572    // exclusive endings. EXEMPT a resume (`resume_phase > 0`) — a `dissolved=1` flag on
573    // a community whose wizard already started is this owner's OWN carrier seal (e.g. a
574    // self-fold sealed it after CARRIER_PUBLISHED, then the flip write flaked), not a foreign
575    // plain dissolution; without the exemption that transient failure reads as false-terminal.
576    if resume_phase == 0 && crate::db::community::get_community_dissolved(&v1_cid).unwrap_or(false) {
577        return Err("this community has been dissolved; it cannot be migrated".to_string());
578    }
579    let Some(owner_hex) = super::service::proven_owner_hex(v1) else {
580        return Err("cannot resolve the community owner".to_string());
581    };
582
583    // Phase 1a — mint (or reload) the v2 twin: primary channel reuses the v1 primary id.
584    emit_migration_progress("Creating the new community...", 15);
585    let twin = if resume_phase >= PHASE_TWIN_MINTED {
586        let (v2_hex, _, _) = ledger.as_ref().unwrap();
587        crate::db::community::load_community_v2(&CommunityId(crate::simd::hex::hex_to_bytes_32(v2_hex)))?
588            .ok_or("migration twin missing on resume")?
589    } else {
590        let primary = v1.channels.first().ok_or("v1 community has no channels")?;
591        let twin = super::v2::service::create_migration_twin(
592            transport,
593            &v1.name,
594            v1.relays.clone(),
595            v1.description.clone(),
596            (primary.id, primary.name.clone()),
597        )
598        .await?;
599        // Ledger the minted identity IMMEDIATELY — before the sibling/banlist tail — so no
600        // crash in that tail can re-mint a second twin (the double-mint orphan window).
601        let v2_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
602        if !session.is_live() {
603            return Err("account changed during twin mint".to_string());
604        }
605        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_TWIN_MINTED, "")?;
606        twin
607    };
608    let v2_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
609
610    // Phase 1b — sibling channels + banlist clone. Idempotent, so a resume at TWIN_MINTED
611    // re-runs the whole tail: a re-created channel publishes a vsk-2 chain ADVANCE with the
612    // same content (readers converge either way) and the local save skips v1-owned rows.
613    if resume_phase < PHASE_TWIN_BUILT {
614        emit_migration_progress("Copying channels, roles and bans...", 35);
615        // Additional v1 channels reuse their ids on the twin (public — v1 has no private
616        // channel model in the shipped protocol, so all stitch as public).
617        for ch in v1.channels.iter().skip(1) {
618            super::v2::service::create_public_channel_with_id(transport, &twin, &ch.name, ch.id).await?;
619        }
620        // Clone the v1 banlist so the v2 join-time ban gate catches v1-banned members.
621        let banlist = crate::db::community::get_community_banlist(&v1_cid).unwrap_or_default();
622        super::v2::service::clone_banlist_to_twin(transport, &twin, &banlist).await?;
623        if !session.is_live() {
624            return Err("account changed during banlist clone".to_string());
625        }
626        // Clone governance: every v1 full admin is re-granted @admin on the twin (owner is
627        // supreme by identity; banned members skipped). Banlist BEFORE governance so a
628        // banned admin never regains authority on v2.
629        let v1_roles = crate::db::community::get_community_roles(&v1_cid).unwrap_or_default();
630        super::v2::service::clone_governance_to_twin(transport, &twin, &v1_roles, &banlist).await?;
631        // The twin persists via save_community_v2 (community row + control plane); its public
632        // channel rows stay v1-owned until the flip re-parents them (the hijack guard).
633        // The ledger needs only the v2 id — a resume reloads the rest from DB + control plane.
634        if !session.is_live() {
635            return Err("account changed during twin build".to_string());
636        }
637        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_TWIN_BUILT, "")?;
638    }
639
640    // Phase 1c — BIRTH REFOUND 0→1 + seed the full v1 roster. Genesis has no snapshot
641    // authority, so the twin must roll to epoch 1 (owner-only rekey) carrying an owner-signed
642    // Guestbook snapshot of the v1 memberlist. After this the twin is at epoch 1 and `m` (next
643    // phase) carries the epoch-1 root. Members join at epoch 1; not-yet-landed seeds show as
644    // members (holding no keys) and RECEIVE every future rotation's blob, so a late migrator
645    // never misses an epoch. Idempotent: mint_or_reuse re-delivers the same epoch-1 root, the
646    // compaction re-wraps the same heads, the snapshot re-publishes (fresh snap_id each run;
647    // convergence is coalesce commutativity, safe because it precedes the carrier).
648    if resume_phase < PHASE_TWIN_REFOUNDED {
649        emit_migration_progress("Securing member access...", 55);
650        let members = v1_snapshot_members(&v1_cid, &owner_hex);
651        super::v2::service::refound_at_birth(transport, &twin, &members).await?;
652        if !session.is_live() {
653            return Err("account changed during birth refound".to_string());
654        }
655        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_TWIN_REFOUNDED, "")?;
656    }
657    // Reload the twin at its CURRENT epoch (1 after the refound) so `m` seals the epoch-1
658    // root, not the throwaway genesis root. On a resume ≥ TWIN_REFOUNDED the persisted twin
659    // is already at epoch 1; this reload makes both paths converge.
660    let twin = crate::db::community::load_community_v2(&twin.identity.community_id)?
661        .ok_or("migration twin missing after birth refound")?;
662
663    // Phase 2 — seal `m` + publish the carrier dissolution on v1 (idempotent: a re-run
664    // re-seals fresh key material under the same root and re-publishes; members dedup).
665    if resume_phase < PHASE_CARRIER_PUBLISHED {
666        emit_migration_progress("Publishing the upgrade for all members...", 75);
667        let jm = super::v2::service::twin_join_material(&twin);
668        let m = seal_m(
669            v1.server_root_key.as_bytes(),
670            &serde_json::to_vec(&jm).map_err(|e| e.to_string())?,
671        )?;
672        let signpost = MigrationSignpost {
673            v2_community_id: v2_hex.clone(),
674            owner_xonly: owner_hex.clone(),
675            owner_salt: crate::simd::hex::bytes_to_hex_32(&twin.identity.owner_salt),
676            relays: twin.relays.clone(),
677            name: v1.name.clone(),
678            primary_channel: v1.channels.first().map(|c| c.id.to_hex()).unwrap_or_default(),
679            root_epoch: v1.server_root_epoch.0,
680        };
681        let content = build_migration_content(&signpost, Some(m))?;
682        super::service::publish_migration_carrier(transport, v1, &content).await?;
683        if !session.is_live() {
684            return Err("account changed during carrier publish".to_string());
685        }
686        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_CARRIER_PUBLISHED, "")?;
687    }
688
689    // Phase 3 — the owner's own local flip: re-parent channel rows + stamp the fence. The
690    // v2 community ROW already exists (create_migration_twin saved it); NO save_community_v2
691    // here — with a channel-less reloaded twin it would PRUNE the just-re-parented rows.
692    // Under the twin's follow lock: a concurrent follow-worker save from a pre-flip
693    // (channel-less) load would prune the rows this txn re-parents.
694    // Straddle the whole wizard's network I/O with the entry guard before the DB write, so
695    // a mid-wizard account swap can't land the flip + ledger row in the wrong account's DB.
696    // `twin` is intentionally not re-saved here (see the fence contract below).
697    emit_migration_progress("Switching you over...", 92);
698    {
699        // Scoped to the flip transaction alone: the follow lock must never be held across
700        // network I/O (the list republish below awaits).
701        let flock = super::v2::realtime::follow_lock(&twin.identity.community_id);
702        let _fguard = flock.lock().await;
703        if !session.is_live() {
704            return Err("account changed during migration".to_string());
705        }
706        crate::db::community::reparent_channels_and_fence(&v1_cid, &v2_hex)?;
707        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_FLIPPED, "")?;
708    }
709
710    // Record the twin in the cross-device community list, the same step `create_community`
711    // takes for a normal v2 community. Sibling devices usually discover the twin by folding
712    // the carrier themselves, but one that no longer holds the v1 community has no carrier to
713    // fold and the list is its only route. Runs AFTER the flip so the list never advertises a
714    // half-built twin (pre-refound it is epoch 0 with no snapshot). Best-effort.
715    // Durable like every other membership record: a twin whose list entry never lands is
716    // the same stranded-behind-a-tombstone hazard as a failed join.
717    match super::v2::service::republish_community_list(transport, Some(&twin.identity.community_id)).await {
718        Ok(true) => {}
719        _ => super::v2::service::republish_community_list_durable(Some(twin.identity.community_id)),
720    }
721    // Stamp the owner's OWN chats as v2 + notify the UI — the wizard doesn't fold its own
722    // carrier, so without this the owner's client would show the stale v1 row until a
723    // later fold/boot. Same finalize the member path uses (idempotent if a self-fold beat us).
724    spawn_finalize_migration(v1_cid, v2_hex.clone());
725    Ok(v2_hex)
726}
727
728/// Post-flip finalize: stamp the stitched chats as the v2 community (name/metadata,
729/// `proto_version` → 2 monotonic, dissolved=false — the ROOM is alive on v2 even though the
730/// v1 row is sealed) and tell the UI. Spawned (std::sync::Arc<crate::db::Session> captured BEFORE the spawn, per
731/// the multi-account contract) so no caller's lock context can deadlock the STATE lock.
732pub fn spawn_finalize_migration(v1_cid: String, v2_hex: String) {
733    crate::db::spawn_bound(async move {
734        let v2_id = CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex));
735        let Ok(Some(twin)) = crate::db::community::load_community_v2(&v2_id) else { return };
736        if !crate::db::current_session().is_live() {
737            return;
738        }
739        crate::register_v2_chats_inner(&twin).await;
740        // Subscribe to the v2 twin's realtime planes — the flip re-pointed the DB but a
741        // migrating MEMBER was never live-listening on the new community, so the owner's
742        // subsequent messages wouldn't arrive (they can still SEND — that path is stateless).
743        // Idempotent for the owner (already following the twin they created). Mirrors the
744        // normal v2-join tail (enqueue_follow + refresh_subscription).
745        super::v2::realtime::enqueue_follow(&twin.identity.community_id);
746        if let Some(client) = crate::state::nostr_client() {
747            super::v2::realtime::refresh_subscription(&client).await;
748        }
749        crate::emit_event(
750            "community_migrated",
751            &serde_json::json!({ "v1_community_id": v1_cid, "v2_community_id": v2_hex }),
752        );
753    });
754}
755
756/// Boot / account-swap maintenance: (1) re-drive every held pointer whose flip hasn't
757/// landed (crash recovery, stale-root retry, unopenable-`m` retry — `drive_migration` is
758/// idempotent and its held-v2 dedup makes the "joined but never flipped" crash a pure
759/// flip on re-run); (2) probe every sealed pointer-less community for a payload the client
760/// missed (the upgrade-lag sweep). Call at boot and after an account swap.
761pub async fn run_migration_maintenance<T: Transport + ?Sized>(transport: &T) -> Vec<String> {
762    let session = crate::db::current_session();
763    let mut flipped = Vec::new();
764    for cid in crate::db::community::migration_flip_candidates().unwrap_or_default() {
765        // The candidate list was pre-fetched: after a swap it describes the WRONG account.
766        if !session.is_live() {
767            return flipped;
768        }
769        let Ok(Some(community)) = crate::db::community::load_community(&CommunityId(
770            crate::simd::hex::hex_to_bytes_32(&cid),
771        )) else {
772            continue;
773        };
774        match drive_migration(transport, &community).await {
775            Ok(Some(v2)) => {
776                spawn_finalize_migration(cid, v2.clone());
777                flipped.push(v2);
778            }
779            // The reason is logged at the exit that knows it, inside drive_migration.
780            Ok(None) => crate::log_debug!("[migration] {cid}: not flipped this pass"),
781            Err(e) => crate::log_warn!("migration retry for {cid}: {e}"),
782        }
783    }
784    flipped.extend(sweep_dissolved_for_migration(transport).await);
785    flipped
786}
787
788/// Boot / account-swap sweep: for every pointer-less, unchecked v1 community — **sealed or
789/// not** — re-probe the rotation-stable dissolved coordinate. Extract + persist any migration
790/// payload and drive the flip; a plain `{}` dissolution is marked checked so it is never
791/// re-probed.
792///
793/// Unsealed candidates matter most. Sealing happens inside the control fold, which the boot
794/// control probe can veto indefinitely: that probe is `since`-windowed over the CONTROL plane
795/// while the authoritative tombstone lives at the DISSOLVED coordinate, so once the cursor
796/// passes it a migrated-away community reads as quiet forever and never seals. Probing only
797/// sealed rows made that state unreachable by every recovery path at once — the community sat
798/// on v1 permanently while its v2 twin, adopted from the Community List, stayed empty (the
799/// stitched channel id is already owned by the unsealed v1 row).
800pub async fn sweep_dissolved_for_migration<T: Transport + ?Sized>(transport: &T) -> Vec<String> {
801    let session = crate::db::current_session();
802    let mut flipped = Vec::new();
803    let candidates = crate::db::community::migration_sweep_candidates().unwrap_or_default();
804    for cid in candidates {
805        // Pre-fetched candidates + a network probe per iteration: re-check the session both
806        // before each community and again between the probe and any write, so a mid-sweep
807        // swap can't persist pointers/checked markers into the new account's rows.
808        if !session.is_live() {
809            return flipped;
810        }
811        let Ok(Some(community)) = crate::db::community::load_community(&CommunityId(
812            crate::simd::hex::hex_to_bytes_32(&cid),
813        )) else {
814            continue;
815        };
816        let Some(owner) = super::service::proven_owner_hex(&community) else {
817            let _ = crate::db::community::set_migration_checked(&cid);
818            continue;
819        };
820        let records = super::service::dissolved_tombstone_records(transport, &community).await;
821        if !session.is_live() {
822            return flipped;
823        }
824        match select_pointer(&records, &owner) {
825            Some((_, raw)) => {
826                let _ = crate::db::community::set_migration_pointer(&cid, &raw);
827                // Re-load so `server_root_epoch` etc. reflect any prior catch-up.
828                if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
829                    if let Ok(Some(v2)) = drive_migration(transport, &fresh).await {
830                        spawn_finalize_migration(cid.clone(), v2.clone());
831                        flipped.push(v2);
832                    }
833                }
834            }
835            None => {
836                // Plain dissolution vs relay-miss vs stranger-only records. Mark checked
837                // (stop re-probing) ONLY when the OWNER's own tombstone is present but carries
838                // no payload — a genuine plain dissolution. A non-owner tombstone is
839                // member-mintable, so a partial-relay probe returning only a stranger's record
840                // must NOT converge the sweep (a real owner carrier could still be unfetched).
841                let owner_sealed = records.iter().any(|d| d.author.to_hex() == owner);
842                if owner_sealed {
843                    let _ = crate::db::community::set_migration_checked(&cid);
844                }
845            }
846        }
847    }
848    flipped
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854    use crate::community::{roster, CommunityId};
855    use nostr_sdk::prelude::*;
856
857    /// `v1_snapshot_members` seeds v1's authoritative memberlist: the owner and a roster
858    /// admin are seeded (the DB re-asserts them), a banned member never is. Uses the DB source
859    /// of truth so the v2 seed can't diverge from what v1 shows.
860    #[test]
861    fn snapshot_member_set_is_v1_memberlist_minus_banned() {
862        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
863        crate::db::close_database();
864        crate::db::clear_id_caches();
865        let acct = Keys::generate().public_key().to_bech32().unwrap();
866        let tmp = tempfile::tempdir().unwrap();
867        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
868        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
869        crate::db::set_current_account(acct.clone()).unwrap();
870        crate::db::init_database(&acct).unwrap();
871
872        let owner = Keys::generate();
873        let admin = Keys::generate();
874        let banned = Keys::generate();
875
876        let mut c = crate::community::Community::create("HQ", "general", vec![]);
877        let cid = c.id.to_hex();
878        {
879            c.owner_attestation = Some(crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
880                .finalize(&owner).unwrap().as_json());
881        }
882        crate::db::community::save_community(&c).unwrap();
883        crate::db::community::set_community_banlist(&cid, &[banned.public_key().to_hex()], 1).unwrap();
884        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
885        let role = Role::admin("aa".repeat(32));
886        crate::db::community::set_community_roles(&cid, &CommunityRoles {
887            roles: vec![role.clone()],
888            grants: vec![
889                MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
890                // A banned member with a stale grant must still be excluded.
891                MemberGrant { member: banned.public_key().to_hex(), role_ids: vec![role.role_id] },
892            ],
893        }, 1).unwrap();
894
895        let members = v1_snapshot_members(&cid, &owner.public_key().to_hex());
896        let has = |k: &Keys| members.iter().any(|m| *m == k.public_key());
897        assert!(has(&owner), "owner is always seeded");
898        assert!(has(&admin), "a roster admin is seeded (re-asserted by v1's memberlist)");
899        assert!(!has(&banned), "a banned member is never seeded, even with a stale grant");
900
901        crate::db::close_database();
902    }
903
904    /// The drive-in-flight claim is exclusive: a second claim on the same cid is refused
905    /// while the first is held, released on drop so a later drive can proceed, and
906    /// per-cid independent. This claim is what serializes the wizard against the owner's
907    /// own carrier self-fold, and boot maintenance against a live fold.
908    #[test]
909    fn drive_claim_is_exclusive_and_releases_on_drop() {
910        // DRIVE_INFLIGHT is process-global: serialize against every other session/DB test.
911        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
912        clear_drive_inflight();
913        let cid = "ab".repeat(32);
914        let other = "cd".repeat(32);
915
916        let first = DriveClaim::take(&cid).expect("a free cid claims");
917        assert!(DriveClaim::take(&cid).is_none(), "a second claim on the same cid is refused");
918        let _independent = DriveClaim::take(&other).expect("a different cid claims freely");
919
920        drop(first);
921        let reclaimed = DriveClaim::take(&cid).expect("drop releases the claim for a later drive");
922        assert!(DriveClaim::take(&other).is_none(), "the other cid is still independently held");
923
924        drop(reclaimed);
925        clear_drive_inflight();
926    }
927
928    /// Drop is generation-aware. An account swap clears the set, so a stale drive still
929    /// unwinding must NOT remove the claim the NEW account's drive just inserted for the
930    /// same cid — that would let a second same-generation drive run concurrently and
931    /// re-open the channel-less-save prune race the claim exists to prevent.
932    #[test]
933    fn drive_claim_drop_is_generation_aware() {
934        // Bumps the global session generation — hold the suite guard so no concurrent test
935        // sees its own std::sync::Arc<crate::db::Session> spuriously invalidated.
936        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
937        clear_drive_inflight();
938        let cid = "ef".repeat(32);
939
940        let stale = DriveClaim::take(&cid).expect("old account's drive claims");
941        // The swap: generation advances and the set is cleared (production `swap_session`).
942        crate::db::close_database();
943        clear_drive_inflight();
944        let fresh = DriveClaim::take(&cid).expect("the new account's drive claims the same cid");
945
946        // The old drive finally unwinds. Its Drop must decline to touch the live claim.
947        drop(stale);
948        assert!(
949            DriveClaim::take(&cid).is_none(),
950            "a stale generation's Drop must not release the current account's claim"
951        );
952
953        // The current claim still releases normally.
954        drop(fresh);
955        assert!(DriveClaim::take(&cid).is_some(), "a valid-generation Drop releases");
956        clear_drive_inflight();
957    }
958
959    /// The state ladder, priority-ordered. The load-bearing case is the SELF-SEAL WINDOW:
960    /// the owner's own carrier fold sets dissolved=1 while the ledger still says the flip is
961    /// pending. A dissolved-first ladder hides the row entirely and strands the owner in the
962    /// one state the resumable wizard was built to recover from.
963    #[test]
964    fn migration_state_ladder_is_priority_ordered() {
965        // migrated is terminal and outranks everything, including a stale ledger row.
966        assert_eq!(migration_state(true, 0, false, true, true), "migrated");
967        assert_eq!(migration_state(true, PHASE_CARRIER_PUBLISHED, true, true, true), "migrated");
968
969        // THE SELF-SEAL WINDOW: sealed v1 + pending ledger + owner ⇒ Resume, never "dissolved".
970        assert_eq!(
971            migration_state(false, PHASE_CARRIER_PUBLISHED, true, true, true),
972            "in_progress",
973            "an owner mid-migration must be offered Resume even though their carrier sealed v1"
974        );
975        assert!(
976            migration_eligible(false, PHASE_CARRIER_PUBLISHED, true, true),
977            "the sealed-but-resumable community stays eligible so the command isn't refused"
978        );
979
980        // A plain dissolution (no ledger) is terminal for the row.
981        assert_eq!(migration_state(false, 0, true, true, true), "dissolved");
982        assert!(!migration_eligible(false, 0, true, true), "a plainly dissolved community is not migratable");
983
984        // A MEMBER never sees an in-progress row: ledger rows only exist on the wizard's own
985        // account, and the is_owner bind makes that structural rather than incidental.
986        assert_eq!(migration_state(false, PHASE_TWIN_MINTED, false, false, true), "not_owner");
987        assert!(!migration_eligible(false, PHASE_TWIN_MINTED, false, false));
988
989        // Owner, clean community: the timelock decides.
990        assert_eq!(migration_state(false, 0, false, true, true), "ready");
991        assert_eq!(migration_state(false, 0, false, true, false), "locked");
992        assert!(migration_eligible(false, 0, false, true), "eligibility is the ownership+fence question, not the clock");
993    }
994
995    /// The timelock is a real gate on the wizard, not just a UI hint: the boundary second
996    /// flips it, and every earlier instant is locked.
997    #[test]
998    fn wizard_timelock_boundary() {
999        assert!(!wizard_unlocked(0));
1000        assert!(!wizard_unlocked(MIGRATION_UNLOCK_AT - 1));
1001        assert!(wizard_unlocked(MIGRATION_UNLOCK_AT), "unlocks exactly at the boundary");
1002        assert!(wizard_unlocked(MIGRATION_UNLOCK_AT + 86_400));
1003    }
1004
1005    fn signpost() -> MigrationSignpost {
1006        MigrationSignpost {
1007            v2_community_id: "aa".repeat(32),
1008            owner_xonly: "bb".repeat(32),
1009            owner_salt: "cc".repeat(32),
1010            relays: vec!["wss://relay.example.com".into()],
1011            name: "Team Rocket".into(),
1012            primary_channel: "dd".repeat(32),
1013            root_epoch: 3,
1014        }
1015    }
1016
1017    #[test]
1018    fn payload_roundtrip() {
1019        let content = build_migration_content(&signpost(), Some("bTEyMw==".into())).unwrap();
1020        let p = parse_migration_payload(&content).unwrap();
1021        assert_eq!(p.signpost, signpost());
1022        assert_eq!(p.m.as_deref(), Some("bTEyMw=="));
1023    }
1024
1025    #[test]
1026    fn plain_dissolution_is_no_payload() {
1027        assert!(parse_migration_payload("{}").is_none());
1028        assert!(parse_migration_payload("").is_none());
1029        assert!(parse_migration_payload("not json at all").is_none());
1030    }
1031
1032    #[test]
1033    fn bad_hex_rejected() {
1034        for field in ["v2_community_id", "owner_xonly", "owner_salt", "primary_channel"] {
1035            let mut sp = signpost();
1036            match field {
1037                "v2_community_id" => sp.v2_community_id = "zz".repeat(32),
1038                "owner_xonly" => sp.owner_xonly = "short".into(),
1039                "owner_salt" => sp.owner_salt = String::new(),
1040                _ => sp.primary_channel = "gg".repeat(32),
1041            }
1042            let content = build_migration_content(&sp, None).unwrap();
1043            assert!(parse_migration_payload(&content).is_none(), "field {field} accepted");
1044        }
1045    }
1046
1047    #[test]
1048    fn bounds_enforced() {
1049        // Oversized m → the whole payload is malformed (fail-safe: plain dissolution).
1050        let content = build_migration_content(&signpost(), Some("A".repeat(MAX_M_B64 + 1))).unwrap();
1051        assert!(parse_migration_payload(&content).is_none());
1052        // Oversized content string → None before any parse.
1053        assert!(parse_migration_payload(&"x".repeat(MAX_PAYLOAD_CONTENT + 1)).is_none());
1054        // Hostile relay list degrades by truncation, never amplifies.
1055        let mut sp = signpost();
1056        sp.relays = (0..40).map(|i| format!("wss://r{i}.example.com")).collect();
1057        sp.name = "n".repeat(500);
1058        let p = parse_migration_payload(&build_migration_content(&sp, None).unwrap()).unwrap();
1059        assert_eq!(p.signpost.relays.len(), crate::community::MAX_COMMUNITY_RELAYS);
1060        assert_eq!(p.signpost.name.chars().count(), MAX_SIGNPOST_NAME);
1061    }
1062
1063    #[test]
1064    fn m_seal_open_multi_root() {
1065        let old_root = [7u8; 32];
1066        let new_root = [8u8; 32];
1067        let sealed = seal_m(&old_root, b"join material").unwrap();
1068        // Newest-first try still finds the older archived root.
1069        let held = vec![(1u64, old_root), (2u64, new_root)];
1070        assert_eq!(open_m(&held, &sealed).unwrap(), b"join material");
1071        // No held root opens it → None (the read-cut member's experience).
1072        assert!(open_m(&[(2u64, new_root)], &sealed).is_none());
1073    }
1074
1075    #[test]
1076    fn seal_errors_past_nip44_cap() {
1077        assert!(seal_m(&[1u8; 32], &vec![0u8; 70_000]).is_err());
1078    }
1079
1080    /// v0.4.0-ACCEPTANCE PIN (the retrofit-certainty condition): an extended tombstone must
1081    /// be accepted as a PLAIN dissolution by the exact shipped code paths — signer extracted
1082    /// at the probe, collected by the fold, content untouched — with a fat payload aboard.
1083    #[test]
1084    fn v040_accepts_extended_tombstone_as_plain_dissolution() {
1085        let owner = Keys::generate();
1086        let cid = CommunityId([0x42u8; 32]);
1087        // ~10 KB of key material — a realistic large community.
1088        let m = Some(base64_simd::STANDARD.encode_to_string(vec![0xabu8; 7_500]));
1089        let content = build_migration_content(&signpost(), m).unwrap();
1090        let inner = roster::build_group_dissolved_edition_with_content(&owner, &cid, 1_753_000_000, &content).unwrap();
1091
1092        // Probe path (dissolved coordinate, id-derived envelope) — v0.4.0's cross-epoch open.
1093        let outer = roster::seal_dissolved_edition(&Keys::generate(), &inner, &cid).unwrap();
1094        let signer = roster::dissolved_tombstone_signer(&outer, &cid).expect("v0.4.0 probe must accept");
1095        assert_eq!(signer, owner.public_key());
1096
1097        // Fold path — dissolved_by (the v0.4.0 seal signal) collects the owner, and the
1098        // v0.4.1 extension carries the content verbatim.
1099        let folded = roster::fold_roster(&[inner.clone()], &cid, &std::collections::HashMap::new());
1100        assert!(folded.dissolved_by.contains(&owner.public_key()));
1101        let rec = folded.dissolved_editions.iter().find(|d| d.author == owner.public_key()).unwrap();
1102        assert_eq!(rec.content, content);
1103
1104        // And the payload-aware probe extracts the same record.
1105        let opened = roster::dissolved_tombstone_open(&outer, &cid).unwrap();
1106        assert_eq!(opened.content, content);
1107        assert_eq!(opened.author, owner.public_key());
1108    }
1109
1110    /// pin: a NEWER payload-less `{}` tombstone seals but never shadows the keys.
1111    #[test]
1112    fn payloadless_never_shadows_the_pointer() {
1113        let owner = Keys::generate();
1114        let stranger = Keys::generate();
1115        let cid = CommunityId([0x24u8; 32]);
1116        let content = build_migration_content(&signpost(), Some("bTEyMw==".into())).unwrap();
1117        let with_payload = roster::build_group_dissolved_edition_with_content(&owner, &cid, 100, &content).unwrap();
1118        let plain_newer = roster::build_group_dissolved_edition(&owner, &cid, 200).unwrap();
1119        let forged = roster::build_group_dissolved_edition_with_content(&stranger, &cid, 300, &content).unwrap();
1120
1121        let folded = roster::fold_roster(&[plain_newer, with_payload, forged], &cid, &std::collections::HashMap::new());
1122        let (p, raw) = select_pointer(&folded.dissolved_editions, &owner.public_key().to_hex()).expect("payload survives");
1123        assert_eq!(p.m.as_deref(), Some("bTEyMw=="));
1124        assert_eq!(parse_migration_payload(&raw).unwrap(), p);
1125        // A stranger's payload alone is never selected.
1126        assert!(select_pointer(&folded.dissolved_editions, &Keys::generate().public_key().to_hex()).is_none());
1127    }
1128
1129    #[test]
1130    fn newest_payload_carrier_wins_with_id_tiebreak() {
1131        let owner = Keys::generate();
1132        let cid = CommunityId([0x33u8; 32]);
1133        let mut sp_old = signpost();
1134        sp_old.name = "old".into();
1135        let mut sp_new = signpost();
1136        sp_new.name = "new".into();
1137        let a = roster::build_group_dissolved_edition_with_content(
1138            &owner, &cid, 100, &build_migration_content(&sp_old, None).unwrap()).unwrap();
1139        let b = roster::build_group_dissolved_edition_with_content(
1140            &owner, &cid, 200, &build_migration_content(&sp_new, None).unwrap()).unwrap();
1141        let folded = roster::fold_roster(&[a, b], &cid, &std::collections::HashMap::new());
1142        let (p, _) = select_pointer(&folded.dissolved_editions, &owner.public_key().to_hex()).unwrap();
1143        assert_eq!(p.signpost.name, "new");
1144    }
1145}