Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/tests/clans_r2.rs

//! **Clans, round 2** (owner, 2026-10-05, answering the round-1 questions),
//! on the real plane:
//!
//! * **f** — "to dissolve, the admin first moves the clan's repos out": the
//!   clan's admin offers a repository to an account; only that account's admin
//!   takes it (or declines; the clan's admin withdraws). Taken, it MOVES —
//!   objects, references, HEAD, LFS objects, branch protection — through the
//!   write paths every push takes, so the transaction log carries it as a
//!   create, a pack, a ref batch, LFS objects and a purge; the clan's room is
//!   freed; the account's room must hold it; and the clan, empty, dissolves.
//! * **d** — "a member stops paying: the clan KEEPS that member's room share
//!   until the member's paid period ends, then the share drops, and if the
//!   clan is then over its room, the push door holds it" (metered).
//! * **h** — "LFS counts in clan room": packs and LFS objects in one room
//!   (metered: the door, the LFS allowance and Usage.Read).

use std::sync::Arc;

use gunnar_core::RepoRef;
use gunnar_grpc::{
    AccountsService, Caller, ClansService, ControlPlane, EventBus, ReposService, ServerConfig,
    ServiceContext,
};
use gunnar_lfs::LfsStore as _;
use gunnar_proto::clans_server::Clans as _;
use gunnar_proto::repos_server::Repos as _;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_store::git_storage_trait::RefUpdate;
use tonic::{Code, Request};

const CLAN: &str = "fellows";
const MIB: u64 = 1 << 20;
#[cfg(feature = "metered")]
const GIB: u64 = 1 << 30;

fn person(id: &str) -> Caller {
    Caller {
        principal_id: id.to_owned(),
        fingerprint: format!("SHA256:{id}"),
        machine: false,
        authenticated: true,
    }
}

fn with<T>(caller: &Caller, body: T) -> Request<T> {
    let mut request = Request::new(body);
    request.extensions_mut().insert(caller.clone());
    request
}

struct Rig {
    _dir: tempfile::TempDir,
    stores: Arc<ServerStores>,
    plane: Arc<CoreControlPlane>,
    clans: ClansService,
    repos: ReposService,
    #[cfg(feature = "metered")]
    usage: gunnar_grpc::UsageService,
    #[allow(dead_code)]
    accounts: AccountsService,
}

impl Rig {
    fn new() -> Rig {
        let dir = tempfile::tempdir().expect("tempdir");
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
        Rig::with(dir, stores, plane)
    }

    /// alice (the clan's admin), bob (its member), carol and dave (outsiders
    /// with accounts of their own), `root` a server admin.
    fn with(
        dir: tempfile::TempDir,
        stores: Arc<ServerStores>,
        plane: Arc<CoreControlPlane>,
    ) -> Rig {
        let control: Arc<dyn ControlPlane> = plane.clone();
        let ctx = ServiceContext::new(
            control,
            Arc::new(EventBus::new(16)),
            ServerConfig::default(),
        );
        for id in ["alice", "bob", "carol", "dave", "root"] {
            plane
                .add_principal(id, id, PrincipalKind::User)
                .expect("account");
        }
        plane
            .grant("root", "", "", GrantLevel::Admin)
            .expect("root is a server admin");
        Rig {
            _dir: dir,
            stores,
            clans: ClansService::new(ctx.clone()),
            repos: ReposService::new(ctx.clone()),
            #[cfg(feature = "metered")]
            usage: gunnar_grpc::UsageService::new(ctx.clone()),
            accounts: AccountsService::new(ctx),
            plane,
        }
    }

    async fn fellows(&self, alice: u64, bob: u64) {
        self.clans
            .create(with(
                &person("alice"),
                CreateClanRequest {
                    name: CLAN.into(),
                    contribution_bytes: alice,
                },
            ))
            .await
            .expect("alice makes the clan");
        self.clans
            .invite(with(
                &person("alice"),
                InviteToClanRequest {
                    clan: CLAN.into(),
                    principal_id: "bob".into(),
                },
            ))
            .await
            .expect("invite bob");
        self.clans
            .accept(with(
                &person("bob"),
                AcceptClanInviteRequest {
                    clan: CLAN.into(),
                    contribution_bytes: bob,
                },
            ))
            .await
            .expect("bob joins");
    }

    async fn get(&self, who: &str) -> Result<Clan, tonic::Status> {
        self.clans
            .get(with(&person(who), GetClanRequest { clan: CLAN.into() }))
            .await
            .map(|r| r.into_inner().clan.expect("a clan"))
    }

    #[cfg(feature = "metered")]
    async fn give(&self, who: &str, bytes: u64) -> Result<Clan, tonic::Status> {
        self.clans
            .set_contribution(with(
                &person(who),
                SetClanContributionRequest {
                    clan: CLAN.into(),
                    contribution_bytes: bytes,
                },
            ))
            .await
            .map(|r| r.into_inner().clan.expect("a clan"))
    }

    async fn offer(&self, who: &str, repo: &str, to: &str) -> Result<Clan, tonic::Status> {
        self.clans
            .offer_repo(with(
                &person(who),
                OfferRepoRequest {
                    clan: CLAN.into(),
                    repo: repo.into(),
                    account: to.into(),
                },
            ))
            .await
            .map(|r| r.into_inner().clan.expect("a clan"))
    }

    async fn answer(
        &self,
        who: &str,
        repo: &str,
        accept: bool,
    ) -> Result<AnswerRepoOfferResponse, tonic::Status> {
        self.clans
            .answer_repo_offer(with(
                &person(who),
                AnswerRepoOfferRequest {
                    clan: CLAN.into(),
                    repo: repo.into(),
                    accept,
                },
            ))
            .await
            .map(|r| r.into_inner())
    }

    async fn make_repo(&self, who: &str, name: &str) {
        self.repos
            .create(with(
                &person(who),
                CreateRepoRequest {
                    path: Some(RepoPath::new(CLAN, name)),
                    hash_kind: HashKind::Sha256 as i32,
                    visibility: Visibility::Private as i32,
                    ..Default::default()
                },
            ))
            .await
            .expect("a member makes a repository in the clan");
    }

    /// A commit — a README blob in a tree — on `refs/heads/main`, HEAD at it,
    /// and `lfs` bytes of one LFS object: written as a push writes, through
    /// the logged store. Answers the commit's hex and the LFS object's oid.
    fn fill(
        &self,
        ns: &str,
        name: &str,
        readme: &[u8],
        lfs: usize,
    ) -> (String, Option<gunnar_lfs::Oid>) {
        let kind = gix_hash::Kind::Sha256;
        let blob = gix_object::compute_hash(kind, gix_object::Kind::Blob, readme).expect("blob id");
        let mut tree = b"100644 README\0".to_vec();
        tree.extend_from_slice(blob.as_slice());
        let tree_id =
            gix_object::compute_hash(kind, gix_object::Kind::Tree, &tree).expect("tree id");
        let commit = format!(
            "tree {tree_id}\nauthor uitest <a@example.invalid> 1790000000 +0000\ncommitter uitest <a@example.invalid> 1790000000 +0000\n\nfirst\n"
        )
        .into_bytes();
        let commit_id =
            gix_object::compute_hash(kind, gix_object::Kind::Commit, &commit).expect("commit id");
        let mut pb = PackBuilder::new(kind);
        pb.object(gix_object::Kind::Blob, readme.to_vec());
        pb.object(gix_object::Kind::Tree, tree);
        pb.object(gix_object::Kind::Commit, commit);
        let path = RepoRef::new(ns, name).expect("legal");
        let repo = self
            .stores
            .catalog()
            .get(&path)
            .expect("read")
            .expect("the repo");
        let handle = self.stores.repo_store(&repo).expect("open");
        handle
            .serve()
            .put_pack(&pb.build().expect("pack").bytes)
            .expect("put_pack");
        handle
            .serve()
            .put_refs(&[RefUpdate::set("refs/heads/main", commit_id.to_string())])
            .expect("put_refs");
        handle.serve().set_head("refs/heads/main").expect("HEAD");
        let oid = (lfs > 0).then(|| {
            let body = vec![0x4cu8; lfs];
            let oid = gunnar_lfs::Oid::of(&body);
            let store = gunnar_lfs::FsLfsStore::open_or_init(handle.git_dir()).expect("lfs store");
            use std::io::Write as _;
            let mut w = store.begin_write(&oid, body.len() as u64).expect("begin");
            w.write_all(&body).expect("write");
            w.commit().expect("commit");
            oid
        });
        drop(handle);
        self.stores.close_repo(&path);
        (commit_id.to_string(), oid)
    }

    /// What `ns` stores once its stores have stopped writing beside a new
    /// pack (indexes land a moment after it): three equal readings in a row,
    /// 300 ms apart.
    fn usage_of(&self, ns: &str) -> NamespaceUsage {
        let read = || self.plane.usage(Some(ns)).expect("usage").remove(0);
        let key = |u: &NamespaceUsage| (u.pack_bytes, u.lfs_bytes, u.repos);
        let (mut last, mut same) = (read(), 0);
        for _ in 0..100 {
            std::thread::sleep(std::time::Duration::from_millis(300));
            let now = read();
            same = if key(&now) == key(&last) { same + 1 } else { 0 };
            last = now;
            if same >= 3 {
                break;
            }
        }
        last
    }

    /// Every transaction-log frame (kind, meta) that names `repo`.
    fn frames_of(&self, repo: &str) -> Vec<(u16, String)> {
        let mut out = Vec::new();
        let needle = format!("\"repo\":\"{repo}\"");
        self.stores
            .txlog()
            .scan_meta(|_, kind, meta| {
                let meta = String::from_utf8_lossy(meta).into_owned();
                if meta.contains(&needle) {
                    out.push((kind, meta));
                }
            })
            .expect("scan the log");
        out
    }
}

// ── f: a repository moves out of the clan ───────────────────────────────────

/// ★ **The clan's admin offers; only the offered account's admin takes it,
/// and then it MOVES** — every object, reference, HEAD and LFS object, as
/// frames the log already carries — the clan's room is freed, the offer is
/// gone, and the clan, empty, dissolves.
#[tokio::test]
async fn a_repository_offered_out_of_the_clan_moves_when_the_accounts_admin_takes_it() {
    let rig = Rig::new();
    rig.fellows(0, 64 * MIB).await;
    rig.make_repo("bob", "app").await;
    let (head, lfs) = rig.fill(CLAN, "app", b"a clan's repository\n", 4096);
    let lfs = lfs.expect("an LFS object");
    assert!(
        rig.usage_of(CLAN).pack_bytes > 0 && rig.usage_of(CLAN).lfs_bytes >= 4096,
        "the fixture is on disk"
    );

    // Only the clan's admin offers; a member and a server admin do not.
    assert_eq!(
        rig.offer("bob", "app", "dave")
            .await
            .expect_err("a member")
            .code(),
        Code::PermissionDenied
    );
    assert_eq!(
        rig.offer("root", "app", "dave")
            .await
            .expect_err("a server admin")
            .code(),
        Code::NotFound
    );
    assert_eq!(
        rig.offer("alice", "nope", "dave")
            .await
            .expect_err("no such repo")
            .code(),
        Code::NotFound
    );
    assert_eq!(
        rig.offer("alice", "app", CLAN)
            .await
            .expect_err("into a clan")
            .code(),
        Code::InvalidArgument
    );
    let clan = rig
        .offer("alice", "app", "dave")
        .await
        .expect("alice offers app to dave");
    assert_eq!(clan.offers.len(), 1, "the admin sees the offer");
    assert_eq!(clan.offers[0].to_account, "dave");
    assert!(
        rig.get("bob").await.unwrap().offers.is_empty(),
        "a member is not shown the offers"
    );

    // dave is told of it; carol is not, and may not answer it.
    let listed = rig
        .clans
        .list(with(&person("dave"), ListClansRequest {}))
        .await
        .unwrap()
        .into_inner();
    assert_eq!(
        listed.offers.len(),
        1,
        "dave's own list carries the offer: {listed:?}"
    );
    assert!(
        listed.offers[0].stored_bytes.is_some_and(|b| b > 0),
        "with what the repository stores"
    );
    assert!(listed.clans.is_empty(), "the offer shows dave no clan");
    let carol = rig
        .clans
        .list(with(&person("carol"), ListClansRequest {}))
        .await
        .unwrap()
        .into_inner();
    assert!(carol.offers.is_empty());
    assert_eq!(
        rig.answer("carol", "app", true)
            .await
            .expect_err("not carol's")
            .code(),
        Code::NotFound
    );
    // The clan's admin may not take it for dave.
    assert_eq!(
        rig.answer("alice", "app", true)
            .await
            .expect_err("not alice's account")
            .code(),
        Code::PermissionDenied
    );

    // dave takes it: it moves.
    let answered = rig
        .answer("dave", "app", true)
        .await
        .expect("dave takes app");
    let moved = answered.repo.expect("the moved repository");
    assert_eq!(moved.path, Some(RepoPath::new("dave", "app")));
    assert_eq!(
        moved.visibility,
        Visibility::Private as i32,
        "it moves as it was"
    );
    assert!(
        rig.plane.get_repo(&RepoPath::new(CLAN, "app")).is_err(),
        "the clan still holds it"
    );
    let to = rig
        .stores
        .catalog()
        .get(&RepoRef::new("dave", "app").unwrap())
        .unwrap()
        .expect("dave/app");
    let store = rig.stores.repo_store(&to).expect("open dave/app");
    let refs = store.serve().refs().expect("refs");
    let main = refs
        .iter()
        .find(|r| r.name == "refs/heads/main")
        .expect("main moved");
    assert_eq!(
        main.oid.as_deref().map(hex::encode),
        Some(head.clone()),
        "main points where it pointed"
    );
    let head_oid = gix_hash::ObjectId::from_hex(head.as_bytes()).expect("hex");
    assert!(
        store
            .serve()
            .get(head_oid.as_slice())
            .expect("read")
            .is_some(),
        "the commit is readable at dave/app"
    );
    assert_eq!(
        store
            .serve()
            .head()
            .unwrap()
            .and_then(|h| h.symref_target)
            .as_deref(),
        Some("refs/heads/main")
    );
    let moved_lfs = gunnar_lfs::FsLfsStore::open_or_init(store.git_dir()).expect("lfs");
    assert_eq!(
        moved_lfs.size_of(&lfs).expect("size"),
        Some(4096),
        "the LFS object moved"
    );
    drop(store);

    // The clan's room is freed, the offer is closed, and the move is in the
    // log as frames a twin already applies.
    let usage = rig.usage_of(CLAN);
    assert_eq!(
        (usage.pack_bytes, usage.lfs_bytes, usage.repos),
        (0, 0, 0),
        "the clan still stores something"
    );
    assert!(
        rig.get("alice").await.unwrap().offers.is_empty(),
        "the offer is still open"
    );
    let frames = rig.frames_of("dave/app");
    for (kind, what) in [(1u16, "the pack"), (2, "the refs"), (6, "the LFS object")] {
        assert!(
            frames.iter().any(|(k, _)| *k == kind),
            "no {what} frame for dave/app: {frames:?}"
        );
    }
    assert!(
        rig.frames_of(&format!("{CLAN}/app"))
            .iter()
            .any(|(k, m)| *k == 8 && m.contains("purged")),
        "the clan's copy was not purged in the log"
    );

    // Empty now: the clan dissolves.
    rig.clans
        .dissolve(with(
            &person("alice"),
            DissolveClanRequest { clan: CLAN.into() },
        ))
        .await
        .expect("an empty clan dissolves");
}

/// ★ **Declined, withdrawn, or not fitting the account's room: nothing
/// moves.** One open offer per repository; a repository already at the
/// target's name is refused.
#[tokio::test]
async fn a_declined_or_withdrawn_offer_moves_nothing() {
    let rig = Rig::new();
    rig.fellows(0, 64 * MIB).await;
    rig.make_repo("bob", "app").await;
    rig.fill(CLAN, "app", b"stays\n", 0);

    rig.offer("alice", "app", "dave").await.expect("offered");
    let err = rig
        .offer("alice", "app", "carol")
        .await
        .expect_err("one open offer per repository");
    assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
    let declined = rig
        .answer("dave", "app", false)
        .await
        .expect("dave declines");
    assert!(declined.repo.is_none());
    assert!(
        rig.plane.get_repo(&RepoPath::new(CLAN, "app")).is_ok(),
        "a declined offer moved it"
    );
    assert!(rig.plane.get_repo(&RepoPath::new("dave", "app")).is_err());

    rig.offer("alice", "app", "carol")
        .await
        .expect("offered again");
    let withdrawn = rig
        .answer("alice", "app", false)
        .await
        .expect("the clan's admin withdraws");
    assert!(withdrawn.repo.is_none());
    assert!(rig.get("alice").await.unwrap().offers.is_empty());
    assert_eq!(
        rig.answer("carol", "app", true)
            .await
            .expect_err("withdrawn")
            .code(),
        Code::NotFound
    );

    // carol already has an `app`: taking it is refused and nothing moves.
    rig.plane
        .create_repo(gunnar_grpc::NewRepo {
            path: RepoPath::new("carol", "app"),
            hash_kind: HashKind::Sha256,
            default_branch: String::new(),
            visibility: Visibility::Private,
            description: String::new(),
            adopt: false,
        })
        .expect("carol/app");
    rig.offer("alice", "app", "carol")
        .await
        .expect("offered to carol");
    let err = rig
        .answer("carol", "app", true)
        .await
        .expect_err("the name is taken in carol's account");
    assert_eq!(err.code(), Code::AlreadyExists, "{err:?}");
    assert!(
        rig.plane.get_repo(&RepoPath::new(CLAN, "app")).is_ok(),
        "a refused take moved it"
    );
    // The clan, still holding it, does not dissolve.
    let err = rig
        .clans
        .dissolve(with(
            &person("alice"),
            DissolveClanRequest { clan: CLAN.into() },
        ))
        .await
        .expect_err("not empty");
    assert_eq!(err.code(), Code::FailedPrecondition);
}

// ── metered: d (a lapsed member's share) and h (LFS in the clan's room) ────

#[cfg(feature = "metered")]
mod metered {
    use super::*;
    use ed25519_dalek::{Signer as _, SigningKey};
    use gunnar_common::monetize_key::MonetizePubkey;
    use gunnar_proto::usage_server::Usage as _;
    use gunnar_server::entitlement::{EntitlementSlot, MeteredConfig};
    use gunnar_ssh::command::GitService;
    use gunnar_ssh::server::{Dispatch, DispatchError};
    use gunnar_ssh::PlanGate as _;
    use monetize_embed::{State, TenantId};

    const DAY: u64 = 86_400_000;

    struct Metered {
        rig: Rig,
        slot: Arc<EntitlementSlot>,
        key: SigningKey,
        minted: std::sync::atomic::AtomicU64,
    }

    fn now() -> u64 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64
    }

    impl Metered {
        fn new() -> Metered {
            let dir = tempfile::tempdir().expect("tempdir");
            let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
            let key = SigningKey::from_bytes(&[7u8; 32]);
            let pubkey = dir.path().join("monetize.pub");
            std::fs::write(&pubkey, key.verifying_key().to_bytes()).expect("write key");
            let config = MeteredConfig::load(
                &MonetizePubkey::Path(pubkey),
                Some("https://gunnar.rs/billing/{tenant}"),
            )
            .expect("config");
            let slot = EntitlementSlot::open(Arc::clone(&stores), &config).expect("slot");
            let plane = Arc::new(
                CoreControlPlane::new(Arc::clone(&stores)).with_entitlement(Arc::clone(&slot)),
            );
            Metered {
                rig: Rig::with(dir, stores, plane),
                slot,
                key,
                minted: std::sync::atomic::AtomicU64::new(0),
            }
        }

        /// `ns` holds `cap` bytes, in `state`, paid until `paid_until`.
        fn fact(&self, ns: &str, cap: u64, state: State, paid_until: u64) {
            let issued = 1_789_000_000_000
                + self
                    .minted
                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            let mut fact = monetize_embed::EntitlementFact {
                tenant: TenantId(ns.to_owned()),
                plan: "room".into(),
                state,
                paid_until_unix_ms: Some(paid_until),
                caps: [("pack_bytes".to_owned(), cap)].into_iter().collect(),
                source: "payment:test".into(),
                signature: vec![],
                issued_unix_ms: Some(issued),
                issued_signature: vec![],
            };
            fact.signature = self
                .key
                .sign(&monetize_embed::signing::fact_message(&fact))
                .to_bytes()
                .to_vec();
            fact.issued_signature = self
                .key
                .sign(&monetize_embed::signing::fact_message_issued(&fact))
                .to_bytes()
                .to_vec();
            self.rig
                .plane
                .entitlement_set(EntitlementFact {
                    namespace: ns.to_owned(),
                    plan: fact.plan,
                    state: match state {
                        State::Free => EntitlementState::Free,
                        State::Paid => EntitlementState::Paid,
                        State::Grace => EntitlementState::Grace,
                        State::Suspended => EntitlementState::Suspended,
                        State::Retention => EntitlementState::Retention,
                    } as i32,
                    paid_until_unix_ms: fact.paid_until_unix_ms,
                    caps: fact.caps,
                    source: fact.source,
                    signature: fact.signature,
                    issued_unix_ms: fact.issued_unix_ms,
                    issued_signature: fact.issued_signature,
                })
                .expect("the fact is accepted");
        }

        fn dispatch(&self) -> Dispatch {
            Dispatch::new(
                self.rig
                    .stores
                    .ssh_control()
                    .with_plan_gate(Arc::clone(&self.slot) as Arc<dyn gunnar_ssh::PlanGate>),
            )
        }

        fn principal(&self, id: &str) -> gunnar_core::Principal {
            self.rig
                .stores
                .auth()
                .principal(id)
                .expect("read")
                .expect("the account")
        }

        async fn allowance(&self, who: &str, ns: &str) -> Allowance {
            self.rig
                .usage
                .read(with(
                    &person(who),
                    ReadUsageRequest {
                        namespace: ns.into(),
                    },
                ))
                .await
                .expect("Usage.Read")
                .into_inner()
                .allowance
                .expect("an allowance")
        }
    }

    /// ★ **d — a member stops paying**: its share stays while its paid period
    /// runs; once the period has ended the share drops — the clan's room in
    /// force is the others', the member is drawn lapsed, the door holds the
    /// clan to what is left, and the floor of the others counts only shares
    /// in force.
    #[tokio::test]
    async fn a_members_share_stays_until_its_paid_period_ends_then_the_door_drops_it() {
        let m = Metered::new();
        m.fact("alice", 10 * GIB, State::Paid, now() + 30 * DAY);
        m.fact("bob", 10 * GIB, State::Paid, now() + 30 * DAY);
        m.rig.fellows(GIB, 2 * GIB).await;
        m.rig.make_repo("bob", "app").await;
        m.rig.fill(CLAN, "app", b"in the clan\n", 0);
        let dispatch = m.dispatch();
        let app = RepoRef::new(CLAN, "app").unwrap();

        // bob stopped paying, but his period runs: his share stays.
        m.fact("bob", 10 * GIB, State::Paid, now() + DAY);
        let clan = m.rig.get("alice").await.unwrap();
        assert_eq!(
            clan.room_in_force_bytes,
            3 * GIB,
            "bob's share is in force while his period runs"
        );
        assert!(clan.members.iter().all(|mm| !mm.lapsed));
        assert_eq!(
            m.allowance("alice", CLAN)
                .await
                .pack_bytes
                .map(|b| b + m.rig.usage_of(CLAN).lfs_bytes),
            Some(3 * GIB)
        );

        // His period has ended (grace is his account's, not the clan's): the share drops.
        m.fact("bob", 10 * GIB, State::Grace, now() - DAY);
        let clan = m.rig.get("alice").await.unwrap();
        assert_eq!(clan.room_bytes, 3 * GIB, "the row keeps bob's share");
        assert_eq!(
            clan.room_in_force_bytes, GIB,
            "the door counts alice's alone"
        );
        assert!(
            clan.members
                .iter()
                .any(|mm| mm.principal_id == "bob" && mm.lapsed),
            "bob is drawn lapsed"
        );
        let ceiling = m
            .slot
            .pack_ceiling(CLAN)
            .expect("a clan's push carries a ceiling");
        let held = m.rig.usage_of(CLAN).pack_bytes;
        assert!(
            ceiling.bytes < 2 * GIB,
            "the door still holds the clan to bob's dropped share: {}",
            ceiling.bytes
        );
        assert_eq!(
            m.allowance("alice", CLAN).await.lfs_bytes,
            Some(GIB - held),
            "Usage.Read says the room in force"
        );

        // alice's floor counts only shares in force: with bob's dropped, she
        // may not go below what the clan stores.
        let used = held + m.rig.usage_of(CLAN).lfs_bytes;
        let err = m
            .rig
            .give("alice", 0)
            .await
            .expect_err("bob's lapsed share holds nothing up");
        assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
        assert_eq!(
            m.rig.get("alice").await.unwrap().your_floor_bytes,
            Some(used)
        );
        // bob, lapsed, may leave: his share is already out of force.
        m.rig
            .clans
            .leave(with(&person("bob"), LeaveClanRequest { clan: CLAN.into() }))
            .await
            .expect("a lapsed member leaves");

        // Over its room in force, the clan is held at the door like an account
        // over its cap: alice gives the clan nothing, bob is gone.
        m.fact("alice", 10 * GIB, State::Suspended, now() - DAY);
        let err = dispatch
            .authorise_service(&m.principal("alice"), None, GitService::ReceivePack, app)
            .expect_err("a clan whose shares all lapsed takes no push");
        let DispatchError::Plan { line, .. } = &err else {
            panic!("{err:?}")
        };
        assert!(line.contains("clan fellows"), "{line}");
    }

    /// ★ **h — LFS counts in the clan's room**: packs and LFS objects are
    /// held against ONE room, at the push door, at the LFS door and in
    /// Usage.Read.
    #[tokio::test]
    async fn lfs_objects_count_in_the_clans_room() {
        let m = Metered::new();
        m.fact("alice", 10 * GIB, State::Paid, now() + 30 * DAY);
        m.rig.fellows(0, 0).await;
        m.rig.make_repo("bob", "app").await;
        m.rig.fill(CLAN, "app", b"small\n", 8 << 20);
        let usage = m.rig.usage_of(CLAN);
        assert!(
            usage.lfs_bytes >= 8 << 20 && usage.pack_bytes < usage.lfs_bytes,
            "the fixture: {usage:?}"
        );
        // Room for the packs but not for packs AND the LFS object.
        m.rig
            .give("alice", usage.pack_bytes + 1)
            .await
            .expect("alice gives a little");
        let dispatch = m.dispatch();
        let err = dispatch
            .authorise_service(
                &m.principal("bob"),
                None,
                GitService::ReceivePack,
                RepoRef::new(CLAN, "app").unwrap(),
            )
            .expect_err("the LFS object fills the clan's room");
        let DispatchError::Plan { line, .. } = &err else {
            panic!("{err:?}")
        };
        assert!(line.contains("pack quota"), "{line}");
        let lfs = m
            .slot
            .lfs_allowance(CLAN)
            .expect("a clan's LFS upload is held to its room");
        assert_eq!(
            lfs.bytes, 0,
            "nothing left for LFS: packs and LFS share the room"
        );
        // Given room for both, the door opens and the LFS allowance is what is left.
        let room = usage.pack_bytes + usage.lfs_bytes + MIB;
        m.rig
            .give("alice", room)
            .await
            .expect("alice gives enough for both");
        dispatch
            .authorise_service(
                &m.principal("bob"),
                None,
                GitService::ReceivePack,
                RepoRef::new(CLAN, "app").unwrap(),
            )
            .expect("room for packs and LFS");
        assert_eq!(m.slot.lfs_allowance(CLAN).expect("held").bytes, MIB);
        let allowed = m.allowance("bob", CLAN).await;
        assert_eq!(
            allowed.pack_bytes,
            Some(room - usage.lfs_bytes),
            "packs may hold the room less the LFS objects"
        );
        assert_eq!(
            allowed.lfs_bytes,
            Some(room - usage.pack_bytes),
            "LFS may hold the room less the packs"
        );
    }

    /// ★ **f — the account's own room must hold what it takes.**
    #[tokio::test]
    async fn an_account_takes_a_repository_only_if_its_own_room_holds_it() {
        let m = Metered::new();
        m.fact("alice", 10 * GIB, State::Paid, now() + 30 * DAY);
        m.rig.fellows(GIB, 0).await;
        m.rig.make_repo("bob", "app").await;
        m.rig.fill(CLAN, "app", b"needs room\n", 200_000);
        // dave's room is 100 KiB: too small.
        m.fact("dave", 100 * 1024, State::Paid, now() + 30 * DAY);
        m.rig.offer("alice", "app", "dave").await.expect("offered");
        let err = m
            .rig
            .answer("dave", "app", true)
            .await
            .expect_err("dave's room does not hold it");
        assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
        assert!(err.message().contains("short"), "{}", err.message());
        assert!(
            m.rig.plane.get_repo(&RepoPath::new(CLAN, "app")).is_ok(),
            "a refused take moved it"
        );
        // With room, it moves.
        m.fact("dave", 10 * GIB, State::Paid, now() + 30 * DAY);
        m.rig
            .answer("dave", "app", true)
            .await
            .expect("now it fits");
        assert!(m.rig.plane.get_repo(&RepoPath::new("dave", "app")).is_ok());
        let _ = TenantId("unused".into());
    }
}

/// **An encrypted repository moved out of the clan stays encrypted** (wiring
/// tracer B 2026-10-08). The move made the new repository from the old one's
/// hash, branch, visibility and description, and dropped the flag: the moved
/// repository then took plaintext pushes, and the members' promise was gone
/// without anyone turning it off.
#[tokio::test]
async fn an_encrypted_repository_moved_out_of_the_clan_stays_encrypted() {
    let rig = Rig::new();
    rig.fellows(0, 64 * MIB).await;
    rig.make_repo("bob", "app").await;
    let _ = rig.fill(CLAN, "app", b"sealed by the members\n", 0);
    rig.plane
        .set_encrypted(&RepoPath::new(CLAN, "app"), true)
        .expect("the clan's admin turned encryption on");
    rig.offer("alice", "app", "dave")
        .await
        .expect("alice offers app to dave");
    let moved = rig
        .answer("dave", "app", true)
        .await
        .expect("dave takes app")
        .repo
        .expect("the moved repository");
    assert!(
        moved.encrypted,
        "the move dropped the encrypted flag: {moved:?}"
    );
    assert!(
        rig.plane
            .get_repo(&RepoPath::new("dave", "app"))
            .expect("dave/app")
            .encrypted,
        "dave/app as the catalog holds it is not encrypted"
    );
}