@@ -9,12 +9,14 @@ public protocol ConversationLeaveWriterProtocol: Sendable {
99 /// the MLS remove-commit finalizes async.
1010 ///
1111 /// The protocol forbids a super admin from leaving (and from being
12- /// removed), so a super-admin leaver is demoted first. When the leaver is
13- /// also the sole super admin, the role is transferred to a remaining
14- /// member before the demotion so the group always keeps at least one
15- /// super admin. `successorCandidates` is the remaining membership
16- /// (excluding the leaver); the writer applies a human-preferred,
17- /// agent-fallback tenure policy to pick who to promote.
12+ /// removed), so a super-admin leaver is demoted first. When no other
13+ /// super admin would remain after the demotion (the leaver is the sole
14+ /// super admin, or every other super admin has announced their own
15+ /// departure), the role is transferred to a remaining member before the
16+ /// demotion so the group always keeps at least one super admin.
17+ /// `successorCandidates` is the remaining membership (excluding the
18+ /// leaver); the writer applies a human-preferred, agent-fallback tenure
19+ /// policy to pick who to promote.
1820 func leave(
1921 conversation: Conversation ,
2022 successorCandidates: [ LeaveSuccessorCandidate ]
@@ -52,17 +54,20 @@ protocol LeaveGroupOperationsProtocol: Sendable {
5254public enum ConversationLeaveError : LocalizedError {
5355 case conversationNotFound( String )
5456 case notGroupConversation( String )
55- /// The MLS self-removal committed in this call, but the optimistic
56- /// consent-hide failed afterwards. The user is off the group, so callers
57- /// should treat the conversation as left; the hide converges on a later
58- /// consent sync. Not thrown when the leave was benignly swallowed (the
59- /// raw hide error propagates instead, so the leave reads as failed and
60- /// stays retryable).
57+ /// The user's membership already ended (the MLS self-removal committed
58+ /// in this call, or a benign rejection showed it was already over or
59+ /// already pending), but the optimistic consent-hide failed afterwards.
60+ /// The user is off the group either way, so callers should treat the
61+ /// conversation as left; the hide converges on a later consent sync. Not
62+ /// thrown for the one benign rejection that keeps the user a member (the
63+ /// single-member group): there the raw hide error propagates instead, so
64+ /// the leave reads as failed and stays retryable.
6165 case hideFailedAfterLeave( String , underlying: any Error )
62- /// The leaver is the sole super admin and no successor could be promoted:
63- /// every candidate announced their own departure since the candidate
64- /// snapshot was taken. The leave aborts so the group is not left with a
65- /// departing member as its only super admin.
66+ /// The leaver is the last super admin not already departing, and no
67+ /// successor could be promoted: every candidate announced their own
68+ /// departure since the candidate snapshot was taken, or none remains.
69+ /// The leave aborts so the group is not left without an active super
70+ /// admin.
6671 case noViableSuccessor( String )
6772
6873 public var errorDescription : String ? {
@@ -146,15 +151,13 @@ final class ConversationLeaveWriter: ConversationLeaveWriterProtocol, @unchecked
146151 ) async throws {
147152 // Self-remove from the MLS roster. Benign outcomes are logged and
148153 // swallowed so the optimistic consent-hide below still runs and the
149- // conversation leaves the UI regardless:
150- // - we're the last member, so libxmtp rejects the 1 -> 0 commit,
151- // - the group is already gone locally or another admin removed us
152- // first (conversationNotFound / NotFound::MlsGroup) -- the leave's
153- // goal is already achieved, only the local hide remains.
154- // The benign filter covers the whole flow because the not-found case
155- // also surfaces from the pre-leave super-admin lookup, not just from
156- // leaveGroup itself.
157- var leaveCommitted = true
154+ // conversation leaves the UI regardless. Each benign case is also
155+ // classified by what it implies for the membership (see
156+ // `benignLeaveOutcome(for:)`) because that decides what a hide
157+ // failure afterwards means. The benign filter covers the whole flow
158+ // because the not-found case also surfaces from the pre-leave
159+ // super-admin lookup, not just from leaveGroup itself.
160+ var membershipEnded = true
158161 do {
159162 let myInboxId = try await operations. currentInboxId ( )
160163 try await relinquishSuperAdminIfNeeded (
@@ -164,33 +167,34 @@ final class ConversationLeaveWriter: ConversationLeaveWriterProtocol, @unchecked
164167 )
165168 try await operations. leaveGroup ( conversationId: conversation. id)
166169 } catch {
167- guard Self . isBenignLeaveError ( error) else { throw error }
168- leaveCommitted = false
170+ guard let outcome = Self . benignLeaveOutcome ( for : error) else { throw error }
171+ membershipEnded = outcome == . membershipEnded
169172 Log . info ( " Leave skipped for \( conversation. id) : \( error. localizedDescription) " )
170173 }
171174
172175 // Optimistic hide, reusing the existing consent-hide path: consent
173176 // `.denied` + push-topic unsubscribe + local row hide. The MLS
174177 // remove-commit is finalized async by an authorized admin/agent.
175- // When the self-removal committed above, a failure here is surfaced
176- // as a typed error: the caller must still treat the conversation as
177- // left rather than as a failed leave. When the leave was benignly
178- // swallowed instead, nothing committed in this call, so the raw hide
179- // error propagates and the leave reads as failed and retryable;
180- // announcing a completed leave here could be false (a single-member
181- // rejection leaves the user a member of a still-visible group) .
178+ // When the membership already ended above (the self-removal
179+ // committed, or a benign rejection showed it was already over), a
180+ // failure here is surfaced as a typed error: the caller must still
181+ // treat the conversation as left rather than as a failed leave. When
182+ // the user is still a member instead (the single-member rejection),
183+ // the raw hide error propagates and the leave reads as failed and
184+ // retryable; announcing a completed leave there would be false .
182185 do {
183186 try await consentWriter. delete ( conversation: conversation)
184187 } catch {
185- guard leaveCommitted else { throw error }
188+ guard membershipEnded else { throw error }
186189 throw ConversationLeaveError . hideFailedAfterLeave ( conversation. id, underlying: error)
187190 }
188191 }
189192
190193 /// The protocol rejects `leaveGroup()` while the leaver is a super admin
191194 /// (super admins cannot be removed), so a super-admin leaver must be
192195 /// demoted before the leave. Protects the "group always keeps at least
193- /// one super admin" invariant: when the leaver is the sole super admin,
196+ /// one super admin" invariant: when no other super admin remains viable -
197+ /// there is none, or every other one has announced their own departure -
194198 /// the preferred successor is promoted before the self-demotion. Awaited
195199 /// rather than best-effort: if the transfer or demotion can't land we
196200 /// abort the leave rather than strand the group with no super admin or
@@ -203,22 +207,49 @@ final class ConversationLeaveWriter: ConversationLeaveWriterProtocol, @unchecked
203207 let superAdmins = try await operations. superAdminInboxIds ( conversationId: conversationId)
204208 guard superAdmins. contains ( myInboxId) else { return }
205209
206- if superAdmins. count == 1 {
210+ let otherSuperAdmins = superAdmins. filter { $0 != myInboxId }
211+ let keepsAViableSuperAdmin : Bool = try await hasViableOtherSuperAdmin (
212+ among: otherSuperAdmins,
213+ conversationId: conversationId
214+ )
215+ if !keepsAViableSuperAdmin {
207216 let ordered = Self . orderedSuccessorInboxIds ( from: successorCandidates, excluding: myInboxId)
208- guard ! ordered. isEmpty else {
217+ if ordered. isEmpty && otherSuperAdmins . isEmpty {
209218 // No one left to promote: the leaver is effectively the last
210219 // member, so keep the role and let `leaveGroup()` resolve it
211220 // (the 1 -> 0 commit is rejected as a benign error).
212221 Log . warning ( " Leaving \( conversationId) as sole super admin with no successor to promote " )
213222 return
214223 }
224+ guard !ordered. isEmpty else {
225+ // Every other super admin announced their own departure and
226+ // no other member remains to promote: demoting self would let
227+ // the group finalize into zero super admins once those leaves
228+ // complete.
229+ throw ConversationLeaveError . noViableSuccessor ( conversationId)
230+ }
215231 try await promoteFirstViableSuccessor ( from: ordered, conversationId: conversationId)
216232 }
217233
218234 try await operations. demoteFromSuperAdmin ( inboxId: myInboxId, conversationId: conversationId)
219235 Log . info ( " Demoted self from super admin before leaving \( conversationId) " )
220236 }
221237
238+ /// True when at least one of the other super admins has not announced
239+ /// their own departure, so the group still has a super admin after the
240+ /// leaver's self-demotion. A departing super admin does not count: their
241+ /// pending removal would finalize the group into zero super admins.
242+ private func hasViableOtherSuperAdmin(
243+ among inboxIds: [ String ] ,
244+ conversationId: String
245+ ) async throws -> Bool {
246+ for inboxId in inboxIds {
247+ let departing = try await hasAnnouncedDeparture ( inboxId: inboxId, conversationId: conversationId)
248+ if !departing { return true }
249+ }
250+ return false
251+ }
252+
222253 /// Tries candidates in preference order. The snapshot behind the
223254 /// candidates is taken before the leave runs, so a candidate can have
224255 /// left the group meanwhile and their promotion is rejected; falling
@@ -298,34 +329,61 @@ final class ConversationLeaveWriter: ConversationLeaveWriterProtocol, @unchecked
298329 }
299330 }
300331
332+ /// What a benign leave rejection implies for the user's membership. The
333+ /// distinction decides what a subsequent consent-hide failure means for
334+ /// the caller.
335+ private enum BenignLeaveOutcome {
336+ /// The membership is already over, or its removal is already pending
337+ /// finalization - the same state a successful `leaveGroup()` leaves
338+ /// behind. A hide failure afterwards still means the user is off the
339+ /// group.
340+ case membershipEnded
341+ /// The rejection keeps the user a member: they are the group's sole
342+ /// member and libxmtp forbids the 1 -> 0 commit. Only the local hide
343+ /// ends this membership, so a hide failure means the leave failed.
344+ case stillMember
345+ }
346+
301347 /// libxmtp surfaces MLS failures as FFI errors whose full message is only
302348 /// visible via `String(describing:)`. `LeaveCantProcessed` is the wrapper
303349 /// for every leave validation failure - including super-admin and DM
304350 /// rejections that mean the user is still a member - so matching on the
305351 /// wrapper name alone would turn those into a silent local hide. Only the
306352 /// specific rejections whose goal is already achieved (or unachievable by
307- /// protocol) are benign, matched by their stable libxmtp messages:
308- /// - "only one member": we're the last member and the 1 -> 0 invariant
309- /// rejects the commit; the group is dead by protocol.
310- /// - "only a member of the group can send a leave request": a concurrent
311- /// removal already took us out before the leave-request landed.
312- /// - "already exists in the pending leave list": a leave-request from a
313- /// retry or another installation is already awaiting finalization.
314- /// - `NotFound::MlsGroup`: a concurrent remove already deleted the group.
315- /// - `conversationNotFound`: the client no longer has the group at all
316- /// (another admin removed us and the welcome/group was purged); the
317- /// local hide is all that's left to do.
318- private static func isBenignLeaveError( _ error: any Error ) -> Bool {
353+ /// protocol) are benign, matched by their stable libxmtp messages, and
354+ /// each carries what it implies for the membership:
355+ /// - "only one member" (still a member): libxmtp raises it only when the
356+ /// user is a member and the sole occupant; the 1 -> 0 invariant rejects
357+ /// the commit and the group is dead by protocol, but only the local
358+ /// hide removes it from view.
359+ /// - "only a member of the group can send a leave request" (ended): a
360+ /// concurrent removal already took us out before the leave-request
361+ /// landed.
362+ /// - "already exists in the pending leave list" (ended): a leave-request
363+ /// from a retry or another installation is already awaiting
364+ /// finalization.
365+ /// - `NotFound::MlsGroup` (ended): a concurrent remove already deleted
366+ /// the group.
367+ /// - `conversationNotFound` (ended): the client no longer has the group
368+ /// at all (another admin removed us and the welcome/group was purged);
369+ /// the local hide is all that's left to do.
370+ /// Returns nil for any other error: not benign, the leave failed.
371+ private static func benignLeaveOutcome( for error: any Error ) -> BenignLeaveOutcome ? {
319372 if case ConversationLeaveError . conversationNotFound = error {
320- return true
373+ return . membershipEnded
321374 }
322375 let description = String ( describing: error)
323- let benignFragments = [
324- " cannot leave a group that has only one member " ,
376+ if description. contains ( " cannot leave a group that has only one member " ) {
377+ return . stillMember
378+ }
379+ let membershipEndedFragments = [
325380 " only a member of the group can send a leave request " ,
326381 " inbox ID already exists in the pending leave list " ,
327382 " NotFound::MlsGroup " ,
328383 ]
329- return benignFragments. contains { description. contains ( $0) }
384+ if membershipEndedFragments. contains ( where: { description. contains ( $0) } ) {
385+ return . membershipEnded
386+ }
387+ return nil
330388 }
331389}
0 commit comments