//! trustin-access — the access core of trustinWallet, as libreDecision uses it //! today. //! //! trustinWallet (formerly trustWallet, a name already taken) is the identity //! and rights layer of the ecosystem. Until it runs as its own service, this //! crate holds its rules so that libreDecision never decides access by itself: //! the server asks `sees` / `opens` / `manages_access`, and stores grants. //! //! Rules (Yvv, 2026-10-21): //! - anyone signed in with a Ğ1 key sees the groups that use the same key — //! Monnaie libre, Axiom, Librodrome, the local group Vallée Drôme Crestois; //! - a group opens only to its members; Monnaie libre's members are the //! members of the Ğ1 web of trust, the others are granted one by one; //! - everything is open to a member except access management, which belongs //! to trustinWallet's administrators; //! - where the electorate is the web of trust (Monnaie libre), only its members //! vote (2026-10-21, independent review: one person, one vote) — through the //! Ğ1 chain, one transaction per vote, for the identity that signs it, member //! when the vote is confirmed (2026-10-30, ADR 0008); //! - a typo fix to someone's version waits for that author; to an authorless //! version (a seeded text), for an administrator — `answers_typo`; //! - an objection is lifted by the person who raised it, or by an //! administrator; it is reiterated by the person who raised it only — //! `lifts_objection` / `reiterates_objection`; //! - each person reads their own messages only — `reads_message` //! (2026-10-31, lot F, ADR 0010); //! - while the chain is closed, the vote is simulated: whoever may ask for a //! ballot votes on the server, marked as simulated — `casts_simulated` //! (2026-11-02, lot V, ADR 0012). //! - the quorum of an article is set by the author of its first version — for a //! text of the seed, by an administrator — and only before the first vote — //! `sets_quorum` (2026-11-03, ADR 0013). use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum IdentityKind { /// Signed in through connect.monnaie-libre.fr: a Ğ1 identity (`idty:`). G1, /// Signed in with an invitation link (tests, before Ğ1 sign-in is wired). Invite, /// Local development only. Dev, } impl IdentityKind { pub fn as_str(self) -> &'static str { match self { Self::G1 => "g1", Self::Invite => "invite", Self::Dev => "dev", } } pub fn parse(raw: &str) -> Option { [Self::G1, Self::Invite, Self::Dev].into_iter().find(|k| k.as_str() == raw) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Identity { pub id: String, pub kind: IdentityKind, pub display_name: String, /// Member of the Ğ1 web of trust at the last sign-in. pub g1_member: bool, /// Administrator of access (trustinWallet). pub admin: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum GroupPolicy { /// Open to every member of the Ğ1 web of trust, plus explicit grants. G1Members, /// Open to explicit grants only. Managed, } impl GroupPolicy { pub fn as_str(self) -> &'static str { match self { Self::G1Members => "g1-members", Self::Managed => "managed", } } pub fn parse(raw: &str) -> Option { [Self::G1Members, Self::Managed].into_iter().find(|p| p.as_str() == raw) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Group { pub slug: String, pub name: String, pub parent: Option, pub policy: GroupPolicy, /// Hidden groups are seen by their members only (the test group). pub hidden: bool, } /// Does this identity belong to the group? `granted` = an explicit grant exists. pub fn is_member(identity: &Identity, group: &Group, granted: bool) -> bool { granted || (group.policy == GroupPolicy::G1Members && identity.kind == IdentityKind::G1 && identity.g1_member) } /// Is the group listed for this identity? pub fn sees(identity: &Identity, group: &Group, granted: bool) -> bool { !group.hidden || is_member(identity, group, granted) || identity.admin } /// Can this identity open the group and use its tools (read, propose, vote)? pub fn opens(identity: &Identity, group: &Group, granted: bool) -> bool { is_member(identity, group, granted) } /// How a group votes. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum VoteWay { /// On the server: one vote per identity of the group (managed groups). Server, /// On the Ğ1 chain: one transaction per vote, signed by the person's wallet /// (ADR 0008). The vote counts for the Ğ1 identity of the signer. Chain, } /// Where the electorate is the Ğ1 web of trust (policy `G1Members`), votes go /// through the chain (Yvv, 2026-10-30: « une transaction par vote »): one /// person, one voice, proven by the identity that signs — not by the way one /// signed in here. Elsewhere, on the server. pub fn vote_way(group: &Group) -> VoteWay { match group.policy { GroupPolicy::G1Members => VoteWay::Chain, GroupPolicy::Managed => VoteWay::Server, } } /// Can this identity vote on the server in the group? In a managed group, every /// member. Where the vote goes through the chain, nobody: an invitation identity /// next to a Ğ1 identity would give one person two voices. pub fn votes_in(identity: &Identity, group: &Group, granted: bool) -> bool { opens(identity, group, granted) && vote_way(group) == VoteWay::Server } /// Can this identity ask for a ballot to sign with a wallet? Whoever opens a /// group that votes on the chain: who votes is decided by the chain itself, at /// confirmation (`chain_vote_counts`). pub fn asks_ballot(identity: &Identity, group: &Group, granted: bool) -> bool { opens(identity, group, granted) && vote_way(group) == VoteWay::Chain } /// Does a vote signed on the chain count? Only for an identity that is a member /// of the web of trust when the vote is confirmed (the electorate W is the web). pub fn chain_vote_counts(member: bool) -> bool { member } /// While the chain is closed (no vote account yet), a group that votes on the /// chain votes in simulation (Yvv, 2026-11-02: « Simule le vote à tout endroit », /// ADR 0012): whoever may ask for a ballot casts a simulated vote, written by the /// server, marked as such, for the identity of the session — there is no wallet. /// One person signed in two ways may then hold two voices: accepted while the /// simulation lasts — Yvv, 2026-11-02: « Il compte, puis s'efface »: every /// simulated vote counts until an act « supprime » sets them all aside. pub fn casts_simulated(identity: &Identity, group: &Group, granted: bool, chain_open: bool) -> bool { !chain_open && asks_ballot(identity, group, granted) } /// Access management stays with trustinWallet's administrators. pub fn manages_access(identity: &Identity) -> bool { identity.admin } /// Who answers a typo fix someone else wrote on a version (2026-10-30, L13): /// its author; for a text without author (a starting text), an administrator of /// trustinWallet. Whoever answers for a version also applies their own fix at once. pub fn answers_typo(identity: &Identity, version_author: Option<&str>) -> bool { match version_author { Some(author) => identity.id == author, None => identity.admin, } } /// Who lifts an objection (2026-10-30, L12): the person who raised it, or an /// administrator of trustinWallet. pub fn lifts_objection(identity: &Identity, objector: &str) -> bool { identity.id == objector || identity.admin } /// Who reiterates an objection: only the person who raised it — an /// administrator lifts, never speaks for someone else. pub fn reiterates_objection(identity: &Identity, objector: &str) -> bool { identity.id == objector } /// A message of trustinWallet is read by its recipient only. pub fn reads_message(identity: &Identity, recipient: &str) -> bool { identity.id == recipient } /// Who sets the quorum of an article (Yvv, 2026-11-02: « L'auteur, avant le 1er vote », /// ADR 0013): the author of its first version; for an article without author (a text of /// the seed), an administrator of trustinWallet — never one for an author. Once a vote /// exists on the article (`voted`, the server's `quorum::a_vote_exists`), nobody: the /// votes cast were judged on it. pub fn sets_quorum(identity: &Identity, first_author: Option<&str>, voted: bool) -> bool { if voted { return false; } match first_author { Some(author) => identity.id == author, None => identity.admin, } } /// The test group: hidden, the quorum 1 at every level — the one place one voice passes. pub const TEST_GROUP: &str = "essai"; /// The groups a fresh deployment starts with. Ordered as displayed. pub fn default_groups() -> Vec { let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group { slug: slug.into(), name: name.into(), parent: parent.map(Into::into), policy, hidden, }; vec![ g("monnaie-libre", "Monnaie libre", None, GroupPolicy::G1Members, false), g("axiom", "Axiom", Some("monnaie-libre"), GroupPolicy::Managed, false), g("librodrome", "Librodrome", Some("monnaie-libre"), GroupPolicy::Managed, false), g( "vallee-drome-crestois", "Groupe local Vallée Drôme Crestois", Some("monnaie-libre"), GroupPolicy::Managed, false, ), g(TEST_GROUP, "Groupe d'essai", None, GroupPolicy::Managed, true), ] } #[cfg(test)] mod tests { use super::*; fn who(kind: IdentityKind, g1_member: bool, admin: bool) -> Identity { Identity { id: "x".into(), kind, display_name: "x".into(), g1_member, admin } } fn group(slug: &str) -> Group { default_groups().into_iter().find(|g| g.slug == slug).expect("default group") } #[test] fn a_wot_member_sees_all_four_and_opens_monnaie_libre_only() { let member = who(IdentityKind::G1, true, false); for slug in ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois"] { assert!(sees(&member, &group(slug), false), "{slug}"); } assert!(opens(&member, &group("monnaie-libre"), false)); assert!(!opens(&member, &group("axiom"), false)); assert!(opens(&member, &group("axiom"), true)); assert!(!manages_access(&member)); } #[test] fn the_test_group_is_hidden_from_non_members() { let member = who(IdentityKind::G1, true, false); assert!(!sees(&member, &group("essai"), false)); assert!(sees(&member, &group("essai"), true)); } #[test] fn a_non_member_key_or_an_invitation_does_not_open_monnaie_libre_by_itself() { let former = who(IdentityKind::G1, false, false); assert!(!opens(&former, &group("monnaie-libre"), false)); let invited = who(IdentityKind::Invite, false, false); assert!(!opens(&invited, &group("monnaie-libre"), false)); assert!(opens(&invited, &group("monnaie-libre"), true)); } #[test] fn administrators_manage_access_and_see_hidden_groups_but_open_nothing_by_right() { let admin = who(IdentityKind::Invite, false, true); assert!(manages_access(&admin)); assert!(sees(&admin, &group("essai"), false)); assert!(!opens(&admin, &group("essai"), false)); } #[test] fn where_the_electorate_is_the_web_of_trust_the_vote_goes_through_the_chain() { let ml = group("monnaie-libre"); assert_eq!(vote_way(&ml), VoteWay::Chain); assert_eq!(vote_way(&group("essai")), VoteWay::Server); // Nobody votes on the server there, whatever the way one signed in. for kind in [IdentityKind::G1, IdentityKind::Invite, IdentityKind::Dev] { assert!(!votes_in(&who(kind, true, false), &ml, true), "{kind:?}"); } // Whoever opens the group may ask for a ballot to sign with a wallet... let invited = who(IdentityKind::Invite, false, true); assert!(opens(&invited, &ml, true), "an invitation may open Monnaie libre to read and propose"); assert!(asks_ballot(&invited, &ml, true)); assert!(asks_ballot(&who(IdentityKind::G1, true, false), &ml, false)); assert!(!asks_ballot(&invited, &ml, false), "not without opening the group"); // ...and the vote counts only for a member of the web of trust, read on the chain. assert!(chain_vote_counts(true)); assert!(!chain_vote_counts(false), "a former member, or an account without identity, does not vote"); } #[test] fn while_the_chain_is_closed_whoever_may_ask_a_ballot_votes_in_simulation() { let ml = group("monnaie-libre"); let invited = who(IdentityKind::Invite, false, false); let member = who(IdentityKind::G1, true, false); assert!(casts_simulated(&invited, &ml, true, false)); assert!(casts_simulated(&member, &ml, false, false)); assert!(!casts_simulated(&invited, &ml, false, false), "not without opening the group"); // The chain open: a ballot to sign, never a simulated vote. assert!(!casts_simulated(&member, &ml, false, true)); // A managed group votes on the server, never in simulation. assert!(!casts_simulated(&invited, &group("essai"), true, false)); } #[test] fn in_a_managed_group_every_member_votes_on_the_server() { let essai = group("essai"); let invited = who(IdentityKind::Invite, false, false); assert!(votes_in(&invited, &essai, true), "managed groups: every member votes"); assert!(!votes_in(&invited, &essai, false)); assert!(!asks_ballot(&invited, &essai, true), "no chain there: the test group stays easy to try"); } #[test] fn the_author_answers_a_typo_and_an_administrator_for_a_starting_text() { let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) }; let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) }; let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) }; assert!(answers_typo(&chiara, Some("chiara"))); assert!(!answers_typo(&other, Some("chiara"))); assert!(!answers_typo(&admin, Some("chiara")), "an administrator never answers for an author"); assert!(answers_typo(&admin, None), "a starting text has no author: an administrator answers"); assert!(!answers_typo(&other, None)); } #[test] fn the_objector_lifts_or_reiterates_an_administrator_only_lifts() { let objector = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) }; let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) }; let other = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) }; assert!(lifts_objection(&objector, "luna") && reiterates_objection(&objector, "luna")); assert!(lifts_objection(&admin, "luna")); assert!(!reiterates_objection(&admin, "luna")); assert!(!lifts_objection(&other, "luna") && !reiterates_objection(&other, "luna")); } #[test] fn the_author_sets_the_quorum_before_the_first_vote_an_administrator_for_the_seed() { let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) }; let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) }; let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) }; assert!(sets_quorum(&chiara, Some("chiara"), false)); assert!(!sets_quorum(&chiara, Some("chiara"), true), "after the first vote, nobody"); assert!(!sets_quorum(&other, Some("chiara"), false)); assert!(!sets_quorum(&admin, Some("chiara"), false), "an administrator never sets it for an author"); assert!(sets_quorum(&admin, None, false), "a text of the seed: an administrator"); assert!(!sets_quorum(&admin, None, true)); assert!(!sets_quorum(&other, None, false)); } #[test] fn a_message_is_read_by_its_recipient_only() { let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) }; let luna = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) }; assert!(reads_message(&luna, "luna")); assert!(!reads_message(&admin, "luna"), "not even an administrator"); } #[test] fn no_atelier_du_canal() { assert!(default_groups().iter().all(|g| !g.slug.contains("atelier"))); } #[test] fn round_trips() { for k in [IdentityKind::G1, IdentityKind::Invite, IdentityKind::Dev] { assert_eq!(IdentityKind::parse(k.as_str()), Some(k)); } for p in [GroupPolicy::G1Members, GroupPolicy::Managed] { assert_eq!(GroupPolicy::parse(p.as_str()), Some(p)); } } }