Skip to main content

gunnar_core/auth/
clan_table.rs

1//! **Clans** (owner, 2026-10-03: "ordinary accounts can group with other in a
2//! clan, where group created repos are administered by the group admin"; and
3//! 2026-10-05: "when built, each member contributes storage to the clan").
4//!
5//! A clan is a group of ORDINARY accounts with a namespace of its own — the
6//! clan's name, created with the clan, private (and its events too) until its
7//! admin says otherwise, as for every account. In the identity table, in the
8//! same compare-and-swap as the grants it widens:
9//!
10//! * **who is in it.** Its admins (the creator first) and its members. An
11//!   account joins by invitation: an admin invites, the account accepts. Only
12//!   a person's own account can be in a clan — never an enterprise account's
13//!   EMPLOYEE (its account admin's rules win), never a machine or a group.
14//! * **what it may do.** Rule 2½ of [`AuthTable::may`]: a member reads and
15//!   writes every repository in the clan's namespace and creates private ones
16//!   there; an ADMIN administers them — visibility, grants, deletion — exactly
17//!   as an account's owner administers the account's. Intrinsic, like account
18//!   ownership: no grant row a `Revoke` could take away.
19//! * **its room.** Every member contributes part of its OWN room; the clan's
20//!   room is the sum. A contribution comes out of what the member's account
21//!   holds and has neither stored nor promised (its employees, its seats, its
22//!   other clans), and goes down only as far as what the clan stores leaves
23//!   room for; leaving returns it, and is refused while it is in use.
24//!
25//! What the caller measures (the member's cap and what it stores, what the
26//! clan stores) comes in as arguments: this table holds identities, not bytes,
27//! exactly as [`AuthTable::enroll_with_room`] takes its room.
28
29use crate::error::{human_bytes, Error, Result};
30use crate::model::{now_ms, Level, PrincipalKind, UnixMillis};
31
32use super::{AuthDoc, AuthTable};
33
34/// **A clan**, as the identity table holds it, keyed by its name — which is
35/// also its namespace.
36#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct Clan {
39    /// The clan's name = its namespace.
40    pub name: String,
41    /// The account that created it (its first admin).
42    pub created_by: String,
43    /// When.
44    pub created_at: UnixMillis,
45    /// Its members, admins included, in the order they joined.
46    #[serde(default)]
47    pub members: Vec<ClanMember>,
48    /// Accounts invited and not yet answered.
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub invites: Vec<ClanInvite>,
51    /// Repositories offered out of the clan, not yet answered (round 2).
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub offers: Vec<ClanRepoOffer>,
54}
55
56/// **A repository offered out of a clan** to an account (owner, 2026-10-05:
57/// "to dissolve, the admin first moves the clan's repos out"): the clan's
58/// admin offers, the account's admin accepts or declines.
59#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct ClanRepoOffer {
62    /// The repository's name in the clan.
63    pub repo: String,
64    /// The account it is offered to.
65    pub to: String,
66    /// The admin who offered it.
67    pub by: String,
68    /// When.
69    pub at: UnixMillis,
70}
71
72/// **One member of a clan.**
73#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct ClanMember {
76    /// The member's account (its principal id).
77    pub principal: String,
78    /// Administers the clan and its repositories.
79    #[serde(default)]
80    pub admin: bool,
81    /// Bytes of the member's OWN room it gives the clan.
82    #[serde(default)]
83    pub contribution_bytes: u64,
84    /// When it joined.
85    pub joined_at: UnixMillis,
86}
87
88/// **An invitation**, waiting for its account's answer.
89#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct ClanInvite {
92    /// The account invited.
93    pub principal: String,
94    /// The admin who invited it.
95    pub by: String,
96    /// When.
97    pub at: UnixMillis,
98}
99
100/// **Where a principal stands in a clan.**
101#[derive(Clone, Copy, PartialEq, Eq, Debug)]
102pub enum ClanRole {
103    /// Administers the clan.
104    Admin,
105    /// A member.
106    Member,
107    /// Invited, not yet answered.
108    Invited,
109}
110
111/// **Which kind of no** a clan verb answered ([`Error::Clan`]).
112#[derive(Clone, Copy, PartialEq, Eq, Debug)]
113pub enum ClanRefusal {
114    /// No such clan (or not one the caller may see).
115    NotFound,
116    /// The caller may not: it is not the clan's admin, or not its member.
117    Denied,
118    /// The rule says no: room, the floor, the last admin, an employee.
119    Precondition,
120    /// The request itself is malformed.
121    Invalid,
122    /// The name is taken (`reason` is then the noun: "a clan called x").
123    Exists,
124}
125
126/// **A member's own account's room**, measured by the caller: its cap
127/// (`None`: nothing caps this account on this server) and what it stores.
128#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
129pub struct AccountRoom {
130    /// The account's room, bytes. `None`: uncapped here.
131    pub cap: Option<u64>,
132    /// What the account stores now, bytes.
133    pub stored: u64,
134}
135
136impl Clan {
137    /// **The clan's room**: the sum of its members' contributions.
138    pub fn room_bytes(&self) -> u64 {
139        self.members
140            .iter()
141            .map(|m| m.contribution_bytes)
142            .fold(0, u64::saturating_add)
143    }
144
145    /// The member row of `principal`, if it is one.
146    pub fn member(&self, principal: &str) -> Option<&ClanMember> {
147        self.members.iter().find(|m| m.principal == principal)
148    }
149
150    /// Where `principal` stands in this clan.
151    pub fn role_of(&self, principal: &str) -> Option<ClanRole> {
152        match self.member(principal) {
153            Some(m) if m.admin => Some(ClanRole::Admin),
154            Some(_) => Some(ClanRole::Member),
155            None if self.invites.iter().any(|i| i.principal == principal) => {
156                Some(ClanRole::Invited)
157            }
158            None => None,
159        }
160    }
161
162    /// How many admins it has.
163    pub fn admins(&self) -> usize {
164        self.members.iter().filter(|m| m.admin).count()
165    }
166
167    /// **How low `member`'s contribution may go** while the clan stores
168    /// `used`: what the clan stores, less what the others give. 0 when the
169    /// others' contributions cover it.
170    pub fn floor_for(&self, member: &str, used: u64) -> u64 {
171        self.floor_in_force(member, used, &[])
172    }
173
174    /// [`Clan::floor_for`] with the shares of `lapsed` members not counted
175    /// (round 2: a member whose paid period has ended gives the clan nothing
176    /// the door counts). A lapsed member's own share is already out of force,
177    /// so its floor is 0.
178    pub fn floor_in_force(&self, member: &str, used: u64, lapsed: &[String]) -> u64 {
179        if lapsed.iter().any(|l| l == member) {
180            return 0;
181        }
182        let others: u64 = self
183            .members
184            .iter()
185            .filter(|m| m.principal != member && !lapsed.contains(&m.principal))
186            .map(|m| m.contribution_bytes)
187            .fold(0, u64::saturating_add);
188        used.saturating_sub(others)
189    }
190
191    /// **The room in force**: the sum of the shares of members not in
192    /// `lapsed`.
193    pub fn room_in_force(&self, lapsed: &[String]) -> u64 {
194        self.members
195            .iter()
196            .filter(|m| !lapsed.contains(&m.principal))
197            .map(|m| m.contribution_bytes)
198            .fold(0, u64::saturating_add)
199    }
200
201    /// The open offer of `repo`, if any.
202    pub fn offer_of(&self, repo: &str) -> Option<&ClanRepoOffer> {
203        self.offers.iter().find(|o| o.repo == repo)
204    }
205}
206
207fn refused(clan: &str, refusal: ClanRefusal, reason: String) -> Error {
208    Error::Clan {
209        clan: clan.to_owned(),
210        refusal,
211        reason,
212    }
213}
214
215impl AuthTable {
216    // ── reads ──────────────────────────────────────────────────────────────
217
218    /// **One clan**, if there is one by that name.
219    pub fn clan(&self, name: &str) -> Result<Option<Clan>> {
220        let doc = self.doc.load()?;
221        if doc.clans.is_empty() {
222            return Ok(None);
223        }
224        doc.clans.get(name)
225    }
226
227    /// **Is `name` a clan's namespace?**
228    pub fn is_clan(&self, name: &str) -> Result<bool> {
229        Ok(self.clan(name)?.is_some())
230    }
231
232    /// **The clans `principal` is in or invited to**, by name.
233    pub fn clans_of(&self, principal: &str) -> Result<Vec<Clan>> {
234        let doc = self.doc.load()?;
235        let mut out = Vec::new();
236        for name in doc.clan_index.get(principal)?.unwrap_or_default() {
237            if let Some(clan) = doc.clans.get(&name)? {
238                out.push(clan);
239            }
240        }
241        Ok(out)
242    }
243
244    /// **Where `principal` stands in the clan whose namespace is `namespace`**,
245    /// or `None` when it is not a clan or `principal` is not in it.
246    pub fn clan_role(&self, principal: &str, namespace: &str) -> Result<Option<ClanRole>> {
247        Ok(self
248            .clan(namespace)?
249            .and_then(|clan| clan.role_of(principal)))
250    }
251
252    /// **What `account` gives all its clans**, bytes — what its own room is
253    /// short of by being in them. One probe and one per clan.
254    pub fn contributed_by(&self, account: &str) -> Result<u64> {
255        let doc = self.doc.load()?;
256        Self::contributed_in(&doc, account, None)
257    }
258
259    /// **What `account` could still give a clan**: its room, less what it
260    /// stores, what its employees and open seats hold, and what it gives its
261    /// clans. `None` when nothing caps the account here.
262    pub fn room_left(&self, account: &str, room: AccountRoom) -> Result<Option<u64>> {
263        let Some(cap) = room.cap else { return Ok(None) };
264        let doc = self.doc.load()?;
265        let committed = room
266            .stored
267            .saturating_add(Self::promised_by(&doc, account)?)
268            .saturating_add(Self::contributed_in(&doc, account, None)?);
269        Ok(Some(cap.saturating_sub(committed)))
270    }
271
272    /// The level a clan gives `principal` in `namespace`: `Admin` for its
273    /// admins, `Write` for its members, `None` otherwise (and for a namespace
274    /// that is no clan's). Rule 2½ of [`AuthTable::may`] and
275    /// [`AuthTable::may_namespace`] — ONE reading for both.
276    pub(super) fn clan_level(
277        doc: &AuthDoc,
278        principal: &str,
279        namespace: &str,
280    ) -> Result<Option<Level>> {
281        if doc.clans.is_empty() {
282            return Ok(None);
283        }
284        let Some(clan) = doc.clans.get(namespace)? else {
285            return Ok(None);
286        };
287        Ok(clan
288            .member(principal)
289            .map(|m| if m.admin { Level::Admin } else { Level::Write }))
290    }
291
292    /// What `account` gives its clans, the clan `except` left out.
293    pub(super) fn contributed_in(
294        doc: &AuthDoc,
295        account: &str,
296        except: Option<&str>,
297    ) -> Result<u64> {
298        if doc.clan_index.is_empty() {
299            return Ok(0);
300        }
301        let mut sum: u64 = 0;
302        for name in doc.clan_index.get(account)?.unwrap_or_default() {
303            if Some(name.as_str()) == except {
304                continue;
305            }
306            if let Some(clan) = doc.clans.get(&name)? {
307                if let Some(m) = clan.member(account) {
308                    sum = sum.saturating_add(m.contribution_bytes);
309                }
310            }
311        }
312        Ok(sum)
313    }
314
315    /// **The clan, if `by` is its admin** — the refusal its admin verbs give
316    /// otherwise, asked without writing (the plane checks this before it
317    /// touches the catalog for a dissolve).
318    pub fn clan_for(&self, clan: &str, by: &str) -> Result<Clan> {
319        let doc = self.doc.load()?;
320        Self::clan_for_admin(&doc, clan, by, "dissolve it")
321    }
322
323    // ── writes ─────────────────────────────────────────────────────────────
324
325    /// **Make a clan**: `name` (its namespace — the caller creates that in the
326    /// catalog), `creator` its first admin, giving `contribution` bytes of its
327    /// own room. The name must be no principal's id and no clan's; the creator
328    /// must be an ordinary account ([`AuthTable::ordinary_account`]).
329    pub fn create_clan(
330        &self,
331        name: &str,
332        creator: &str,
333        contribution: u64,
334        room: AccountRoom,
335    ) -> Result<Clan> {
336        crate::sandbox::check_component(name, name)?;
337        if name == crate::model::ANONYMOUS_PRINCIPAL_ID {
338            return Err(Error::ReservedPrincipalId(name.to_owned()));
339        }
340        let at = now_ms();
341        self.doc.update(|doc| {
342            Self::ordinary_account(doc, name, creator, "make a clan")?;
343            if doc.clans.contains_key(name)? {
344                return Err(refused(
345                    name,
346                    ClanRefusal::Exists,
347                    format!("a clan called {name}"),
348                ));
349            }
350            if doc.principals.contains_key(name)? {
351                return Err(refused(
352                    name,
353                    ClanRefusal::Exists,
354                    format!("an account called {name}"),
355                ));
356            }
357            Self::fits_in_room(doc, name, creator, contribution, room)?;
358            let clan = Clan {
359                name: name.to_owned(),
360                created_by: creator.to_owned(),
361                created_at: at,
362                members: vec![ClanMember {
363                    principal: creator.to_owned(),
364                    admin: true,
365                    contribution_bytes: contribution,
366                    joined_at: at,
367                }],
368                invites: Vec::new(),
369                offers: Vec::new(),
370            };
371            doc.clans.insert(name.to_owned(), clan.clone())?;
372            Self::index_clan(doc, creator, name)?;
373            Ok(clan)
374        })
375    }
376
377    /// **Invite `principal`** into `clan` — an admin's (`by`). The account
378    /// must be an ordinary one, not in the clan and not invited already.
379    pub fn invite_to_clan(&self, clan: &str, by: &str, principal: &str) -> Result<Clan> {
380        let at = now_ms();
381        self.doc.update(|doc| {
382            let mut row = Self::clan_for_admin(doc, clan, by, "invite an account")?;
383            Self::ordinary_account(doc, clan, principal, "join a clan")?;
384            match row.role_of(principal) {
385                Some(ClanRole::Admin | ClanRole::Member) => {
386                    return Err(refused(
387                        clan,
388                        ClanRefusal::Precondition,
389                        format!("{principal} is already in clan {clan}"),
390                    ))
391                }
392                Some(ClanRole::Invited) => {
393                    return Err(refused(
394                        clan,
395                        ClanRefusal::Precondition,
396                        format!(
397                            "{principal} is already invited to clan {clan}, and has not answered"
398                        ),
399                    ))
400                }
401                None => {}
402            }
403            row.invites.push(ClanInvite {
404                principal: principal.to_owned(),
405                by: by.to_owned(),
406                at,
407            });
408            doc.clans.insert(clan.to_owned(), row.clone())?;
409            Self::index_clan(doc, principal, clan)?;
410            Ok(row)
411        })
412    }
413
414    /// **The invited account answers.** `accept`: it joins, giving
415    /// `contribution` bytes of its own room (checked against `room`, as any
416    /// raise is); otherwise the invitation is declined. Only the invited
417    /// account answers its own invitation.
418    pub fn answer_clan_invite(
419        &self,
420        clan: &str,
421        principal: &str,
422        accept: bool,
423        contribution: u64,
424        room: AccountRoom,
425    ) -> Result<Clan> {
426        let at = now_ms();
427        self.doc.update(|doc| {
428            let mut row = Self::visible_clan(doc, clan, principal)?;
429            if row.role_of(principal) != Some(ClanRole::Invited) {
430                return Err(refused(
431                    clan,
432                    ClanRefusal::Precondition,
433                    format!("{principal} has no invitation to clan {clan} to answer"),
434                ));
435            }
436            row.invites.retain(|i| i.principal != principal);
437            if accept {
438                // The rules again, at the moment of joining: an account may
439                // have become an employer's since it was invited, never an
440                // employee (that is only ever a NEW principal), but it may
441                // have been locked out.
442                Self::ordinary_account(doc, clan, principal, "join a clan")?;
443                Self::fits_in_room(doc, clan, principal, contribution, room)?;
444                row.members.push(ClanMember {
445                    principal: principal.to_owned(),
446                    admin: false,
447                    contribution_bytes: contribution,
448                    joined_at: at,
449                });
450            } else {
451                Self::unindex_clan(doc, principal, clan)?;
452            }
453            doc.clans.insert(clan.to_owned(), row.clone())?;
454            Ok(row)
455        })
456    }
457
458    /// **An admin withdraws an invitation** it (or another admin) made.
459    pub fn withdraw_clan_invite(&self, clan: &str, by: &str, principal: &str) -> Result<Clan> {
460        self.doc.update(|doc| {
461            let mut row = Self::clan_for_admin(doc, clan, by, "withdraw an invitation")?;
462            if row.role_of(principal) != Some(ClanRole::Invited) {
463                return Err(refused(
464                    clan,
465                    ClanRefusal::Precondition,
466                    format!("{principal} has no invitation to clan {clan}"),
467                ));
468            }
469            row.invites.retain(|i| i.principal != principal);
470            Self::unindex_clan(doc, principal, clan)?;
471            doc.clans.insert(clan.to_owned(), row.clone())?;
472            Ok(row)
473        })
474    }
475
476    /// **A member sets its own contribution** to `bytes`. Up: out of its own
477    /// room (`room`). Down: no lower than what the clan stores (`used`) leaves
478    /// room for — `None` (not measured) refuses a lowering rather than
479    /// guessing it is free.
480    pub fn set_clan_contribution(
481        &self,
482        clan: &str,
483        member: &str,
484        bytes: u64,
485        room: AccountRoom,
486        used: Option<u64>,
487        lapsed: &[String],
488    ) -> Result<Clan> {
489        self.doc.update(|doc| {
490            let mut row = Self::visible_clan(doc, clan, member)?;
491            Self::refuse_gated(doc, clan, member)?;
492            let Some(now) = row.member(member).map(|m| m.contribution_bytes) else {
493                return Err(refused(
494                    clan,
495                    ClanRefusal::Denied,
496                    format!("{member} is not a member of clan {clan}: only a member gives it room"),
497                ));
498            };
499            if bytes > now {
500                Self::fits_in_room(doc, clan, member, bytes, room)?;
501            } else if bytes < now {
502                Self::above_floor(
503                    &row,
504                    clan,
505                    member,
506                    bytes,
507                    used,
508                    lapsed,
509                    "lower its contribution",
510                )?;
511            }
512            for m in &mut row.members {
513                if m.principal == member {
514                    m.contribution_bytes = bytes;
515                }
516            }
517            doc.clans.insert(clan.to_owned(), row.clone())?;
518            Ok(row)
519        })
520    }
521
522    /// **A member leaves**: its contribution goes back to its own account.
523    /// Refused while the clan uses it (`used` past what the others give), and
524    /// for the clan's last admin.
525    pub fn leave_clan(
526        &self,
527        clan: &str,
528        member: &str,
529        used: Option<u64>,
530        lapsed: &[String],
531    ) -> Result<Clan> {
532        self.doc.update(|doc| {
533            let mut row = Self::visible_clan(doc, clan, member)?;
534            Self::refuse_gated(doc, clan, member)?;
535            let Some(me) = row.member(member).cloned() else {
536                return Err(refused(clan, ClanRefusal::Precondition, format!("{member} is not a member of clan {clan}")));
537            };
538            if me.admin && row.admins() == 1 {
539                return Err(refused(
540                    clan,
541                    ClanRefusal::Precondition,
542                    format!(
543                        "{member} is clan {clan}'s last admin, and the last admin cannot leave: make another \
544                         member admin first, or dissolve the clan"
545                    ),
546                ));
547            }
548            Self::above_floor(&row, clan, member, 0, used, lapsed, "leave")?;
549            row.members.retain(|m| m.principal != member);
550            Self::unindex_clan(doc, member, clan)?;
551            doc.clans.insert(clan.to_owned(), row.clone())?;
552            Ok(row)
553        })
554    }
555
556    /// **An admin removes a member** — not itself (that is leaving), and not
557    /// while the clan uses the member's contribution; its contribution goes
558    /// back to its own account.
559    pub fn remove_from_clan(
560        &self,
561        clan: &str,
562        by: &str,
563        member: &str,
564        used: Option<u64>,
565        lapsed: &[String],
566    ) -> Result<Clan> {
567        self.doc.update(|doc| {
568            let mut row = Self::clan_for_admin(doc, clan, by, "remove a member")?;
569            if member == by {
570                return Err(refused(
571                    clan,
572                    ClanRefusal::Invalid,
573                    format!("{by} cannot remove itself from clan {clan}: that is leaving"),
574                ));
575            }
576            if row.member(member).is_none() {
577                return Err(refused(
578                    clan,
579                    ClanRefusal::Precondition,
580                    format!("{member} is not a member of clan {clan}"),
581                ));
582            }
583            Self::above_floor(&row, clan, member, 0, used, lapsed, "be removed")?;
584            row.members.retain(|m| m.principal != member);
585            Self::unindex_clan(doc, member, clan)?;
586            doc.clans.insert(clan.to_owned(), row.clone())?;
587            Ok(row)
588        })
589    }
590
591    /// **An admin makes a member admin, or not.** The last admin is never
592    /// made a plain member.
593    pub fn set_clan_admin(&self, clan: &str, by: &str, member: &str, admin: bool) -> Result<Clan> {
594        self.doc.update(|doc| {
595            let mut row = Self::clan_for_admin(doc, clan, by, "change who administers it")?;
596            let Some(was) = row.member(member).map(|m| m.admin) else {
597                return Err(refused(
598                    clan,
599                    ClanRefusal::Precondition,
600                    format!("{member} is not a member of clan {clan}"),
601                ));
602            };
603            if was && !admin && row.admins() == 1 {
604                return Err(refused(
605                    clan,
606                    ClanRefusal::Precondition,
607                    format!(
608                        "{member} is clan {clan}'s last admin: make another member admin first"
609                    ),
610                ));
611            }
612            for m in &mut row.members {
613                if m.principal == member {
614                    m.admin = admin;
615                }
616            }
617            doc.clans.insert(clan.to_owned(), row.clone())?;
618            Ok(row)
619        })
620    }
621
622    /// **An admin dissolves the clan**: every member's contribution goes
623    /// back, every invitation goes. The caller checks first that the clan's
624    /// namespace holds nothing, and removes the namespace after.
625    pub fn dissolve_clan(&self, clan: &str, by: &str) -> Result<Clan> {
626        self.doc.update(|doc| {
627            let row = Self::clan_for_admin(doc, clan, by, "dissolve it")?;
628            for m in &row.members {
629                Self::unindex_clan(doc, &m.principal, clan)?;
630            }
631            for i in &row.invites {
632                Self::unindex_clan(doc, &i.principal, clan)?;
633            }
634            doc.clans.remove(clan)?;
635            Ok(row)
636        })
637    }
638
639    // ── moving a repository out (round 2) ──────────────────────────────────
640
641    /// **The clan's admin offers `repo` to account `to`.** The plane checks
642    /// first that the repository is the clan's; this checks who may offer and
643    /// to whom: an account (a person's, a principal of its own — never a
644    /// machine, a group, an employee or another clan), one open offer per
645    /// repository.
646    pub fn offer_clan_repo(&self, clan: &str, by: &str, repo: &str, to: &str) -> Result<Clan> {
647        let at = now_ms();
648        self.doc.update(|doc| {
649            let mut row = Self::clan_for_admin(doc, clan, by, "move a repository out of it")?;
650            if to == clan || doc.clans.contains_key(to)? {
651                return Err(refused(
652                    clan,
653                    ClanRefusal::Invalid,
654                    format!("{to} is a clan: a repository moves out of a clan into an account"),
655                ));
656            }
657            let Some(account) = doc.principals.get(to)? else {
658                return Err(refused(
659                    clan,
660                    ClanRefusal::NotFound,
661                    format!("there is no account {to}"),
662                ));
663            };
664            if account.kind != PrincipalKind::User || doc.employers.contains_key(to)? {
665                return Err(refused(
666                    clan,
667                    ClanRefusal::Precondition,
668                    format!("{to} holds no account of its own to move {clan}/{repo} into"),
669                ));
670            }
671            if let Some(open) = row.offer_of(repo) {
672                return Err(refused(
673                    clan,
674                    ClanRefusal::Precondition,
675                    format!(
676                        "{clan}/{repo} is already offered to {}; withdraw that offer first",
677                        open.to
678                    ),
679                ));
680            }
681            row.offers.push(ClanRepoOffer {
682                repo: repo.to_owned(),
683                to: to.to_owned(),
684                by: by.to_owned(),
685                at,
686            });
687            doc.clans.insert(clan.to_owned(), row.clone())?;
688            Ok(row)
689        })
690    }
691
692    /// The open offer of `clan/repo`, if any.
693    pub fn clan_repo_offer(&self, clan: &str, repo: &str) -> Result<Option<ClanRepoOffer>> {
694        Ok(self.clan(clan)?.and_then(|row| row.offer_of(repo).cloned()))
695    }
696
697    /// **Every open offer to account `to`**, as `(clan, offer)`, by clan. A
698    /// listing: it walks the clans.
699    pub fn clan_offers_to(&self, to: &str) -> Result<Vec<(String, ClanRepoOffer)>> {
700        let doc = self.doc.load()?;
701        if doc.clans.is_empty() {
702            return Ok(Vec::new());
703        }
704        let mut out = Vec::new();
705        for (name, row) in doc.clans.entries()? {
706            for offer in row.offers {
707                if offer.to == to {
708                    out.push((name.clone(), offer));
709                }
710            }
711        }
712        Ok(out)
713    }
714
715    /// **The clan's admin withdraws an offer.**
716    pub fn withdraw_clan_repo_offer(&self, clan: &str, by: &str, repo: &str) -> Result<Clan> {
717        self.doc.update(|doc| {
718            let mut row = Self::clan_for_admin(doc, clan, by, "withdraw an offer")?;
719            if row.offer_of(repo).is_none() {
720                return Err(refused(
721                    clan,
722                    ClanRefusal::Precondition,
723                    format!("{clan}/{repo} is offered to nobody"),
724                ));
725            }
726            row.offers.retain(|o| o.repo != repo);
727            doc.clans.insert(clan.to_owned(), row.clone())?;
728            Ok(row)
729        })
730    }
731
732    /// **The offer is answered** (declined, or taken once the move is done):
733    /// it goes, and the offer as it was comes back. Who may answer — the
734    /// offered account's admin — is the caller's to have asked; this only
735    /// checks the offer is the one named.
736    pub fn close_clan_repo_offer(&self, clan: &str, repo: &str, to: &str) -> Result<ClanRepoOffer> {
737        self.doc.update(|doc| {
738            let mut row = doc.clans.get(clan)?.ok_or_else(|| {
739                refused(
740                    clan,
741                    ClanRefusal::NotFound,
742                    format!("there is no clan {clan}"),
743                )
744            })?;
745            let Some(offer) = row.offer_of(repo).filter(|o| o.to == to).cloned() else {
746                return Err(refused(
747                    clan,
748                    ClanRefusal::NotFound,
749                    format!("{clan}/{repo} is not offered to {to}"),
750                ));
751            };
752            row.offers.retain(|o| o.repo != repo);
753            doc.clans.insert(clan.to_owned(), row)?;
754            Ok(offer)
755        })
756    }
757
758    // ── inside the compare-and-swap ────────────────────────────────────────
759
760    /// **A principal that is going** (`remove_principal`): out of every clan
761    /// it is in or invited to, its contribution with it. A clan it was the
762    /// last admin of is handed to its longest-standing member, so no clan is
763    /// left with nobody to administer it.
764    pub(super) fn forget_clan_member(doc: &mut AuthDoc, id: &str) -> Result<()> {
765        if doc.clan_index.is_empty() {
766            return Ok(());
767        }
768        for name in doc.clan_index.remove(id)?.unwrap_or_default() {
769            let Some(mut row) = doc.clans.get(&name)? else {
770                continue;
771            };
772            row.members.retain(|m| m.principal != id);
773            row.invites.retain(|i| i.principal != id);
774            if row.admins() == 0 {
775                if let Some(first) = row.members.iter_mut().min_by_key(|m| m.joined_at) {
776                    first.admin = true;
777                }
778            }
779            doc.clans.insert(name, row)?;
780        }
781        Ok(())
782    }
783
784    /// The clan `clan`, for a verb only its admin `by` may make.
785    fn clan_for_admin(doc: &AuthDoc, clan: &str, by: &str, what: &str) -> Result<Clan> {
786        let row = Self::visible_clan(doc, clan, by)?;
787        Self::refuse_gated(doc, clan, by)?;
788        if row.role_of(by) != Some(ClanRole::Admin) {
789            return Err(refused(
790                clan,
791                ClanRefusal::Denied,
792                format!("only clan {clan}'s admin may {what}; {by} is not its admin"),
793            ));
794        }
795        Ok(row)
796    }
797
798    /// The clan `clan` as `who` may know it: a member's or an invited
799    /// account's. To anybody else it does not exist — the same answer as a
800    /// clan that does not, so a name is never confirmed to an outsider.
801    fn visible_clan(doc: &AuthDoc, clan: &str, who: &str) -> Result<Clan> {
802        match doc.clans.get(clan)? {
803            Some(row) if row.role_of(who).is_some() => Ok(row),
804            _ => Err(refused(
805                clan,
806                ClanRefusal::NotFound,
807                format!("there is no clan {clan} you are in or invited to"),
808            )),
809        }
810    }
811
812    /// **Only an ORDINARY account is in a clan**: a person (a user) that
813    /// exists, that is no employee — an enterprise account's people belong to
814    /// their account, whose admin's rules win — and that is not locked out.
815    fn ordinary_account(doc: &AuthDoc, clan: &str, id: &str, what: &str) -> Result<()> {
816        let Some(principal) = doc.principals.get(id)? else {
817            return Err(refused(
818                clan,
819                ClanRefusal::NotFound,
820                format!("there is no account {id}"),
821            ));
822        };
823        if principal.kind != PrincipalKind::User {
824            return Err(refused(
825                clan,
826                ClanRefusal::Precondition,
827                format!(
828                    "{id} is a {:?}, and only a person's own account may {what}",
829                    principal.kind
830                ),
831            ));
832        }
833        if let Some(employer) = doc.employers.get(id)? {
834            return Err(refused(
835                clan,
836                ClanRefusal::Precondition,
837                format!(
838                    "{id} is an employee of {employer}: an enterprise account's people may not {what} — \
839                     their account admin's rules win"
840                ),
841            ));
842        }
843        Self::refuse_gated(doc, clan, id)
844    }
845
846    /// A locked-out account acts nowhere — not in a clan either.
847    fn refuse_gated(doc: &AuthDoc, clan: &str, id: &str) -> Result<()> {
848        if Self::gated(doc, id)? {
849            return Err(refused(
850                clan,
851                ClanRefusal::Precondition,
852                format!("account {id} is locked"),
853            ));
854        }
855        Ok(())
856    }
857
858    /// **Does `bytes` for `clan` fit in `account`'s own room?** What the
859    /// account stores, what its employees and open seats hold and what it
860    /// gives its OTHER clans are taken; `bytes` must fit in the rest. An
861    /// uncapped account fits anything.
862    fn fits_in_room(
863        doc: &AuthDoc,
864        clan: &str,
865        account: &str,
866        bytes: u64,
867        room: AccountRoom,
868    ) -> Result<()> {
869        let Some(cap) = room.cap else { return Ok(()) };
870        let committed = room
871            .stored
872            .saturating_add(Self::promised_by(doc, account)?)
873            .saturating_add(Self::contributed_in(doc, account, Some(clan))?);
874        let free = cap.saturating_sub(committed);
875        if bytes > free {
876            return Err(refused(
877                clan,
878                ClanRefusal::Precondition,
879                format!(
880                    "{account} has {} of its own room unused, and {} is asked for clan {clan}: {} short. \
881                     A clan's room is its members' own; add room to the account, or give less",
882                    human_bytes(free),
883                    human_bytes(bytes),
884                    human_bytes(bytes - free)
885                ),
886            ));
887        }
888        Ok(())
889    }
890
891    /// **Is the clan's room, with `member` giving `bytes`, still at least what
892    /// it stores?** `used` unmeasured refuses.
893    fn above_floor(
894        row: &Clan,
895        clan: &str,
896        member: &str,
897        bytes: u64,
898        used: Option<u64>,
899        lapsed: &[String],
900        what: &str,
901    ) -> Result<()> {
902        let Some(used) = used else {
903            return Err(refused(
904                clan,
905                ClanRefusal::Precondition,
906                format!(
907                    "what clan {clan} stores could not be measured, so {member} may not {what} now: a room \
908                     that is in use is never given back unseen"
909                ),
910            ));
911        };
912        let floor = row.floor_in_force(member, used, lapsed);
913        if bytes < floor {
914            let own = row.member(member).map_or(0, |m| m.contribution_bytes);
915            return Err(refused(
916                clan,
917                ClanRefusal::Precondition,
918                format!(
919                    "clan {clan} stores {} in its room of {}, so {member}'s {} is in use: it may go down to {} \
920                     and no lower, and {member} may not {what} until the clan stores less",
921                    human_bytes(used),
922                    human_bytes(row.room_in_force(lapsed)),
923                    human_bytes(own),
924                    human_bytes(floor)
925                ),
926            ));
927        }
928        Ok(())
929    }
930
931    fn index_clan(doc: &mut AuthDoc, principal: &str, clan: &str) -> Result<()> {
932        let mut names = doc.clan_index.get(principal)?.unwrap_or_default();
933        if !names.iter().any(|n| n == clan) {
934            names.push(clan.to_owned());
935            names.sort();
936        }
937        doc.clan_index.insert(principal.to_owned(), names)?;
938        Ok(())
939    }
940
941    fn unindex_clan(doc: &mut AuthDoc, principal: &str, clan: &str) -> Result<()> {
942        let Some(mut names) = doc.clan_index.get(principal)? else {
943            return Ok(());
944        };
945        names.retain(|n| n != clan);
946        if names.is_empty() {
947            doc.clan_index.remove(principal)?;
948        } else {
949            doc.clan_index.insert(principal.to_owned(), names)?;
950        }
951        Ok(())
952    }
953}