Files
decision/crates/trustin-access/src/lib.rs
T
YvvandClaude Opus 5.5 3f1f968f16 v3 : serveur des objections, des coquilles validées et de la messagerie (lot F)
Objections « redit » / « contredit » à la place des signalements : la
dernière arrivée des deux versions n'est plus votable (repli, panier,
chaîne : un vote déjà scellé est gardé mais ne la fait pas entrer) ; un
texte en vigueur ne l'est jamais. L'auteur répond en modifiant l'article
visé (proposition liée) ou en reformulant ; la personne qui objecte lève
ou réitère depuis ses messages ; sans réponse sous 3 jours, l'objection
est levée. Un administrateur peut lever.

Coquilles : la sienne passe tout de suite ; celle d'un autre attend
l'auteur (valider / refuser), passe seule au bout de 3 jours, et l'auteur
garde la main tant que le texte n'est pas en vigueur. En vigueur, seule
une coquille validée corrige (auteur de la version, administrateur pour
un texte semé). Deux coquilles en attente : la seconde se mesure au texte
voté ; à l'application, celle qui ne colle plus est périmée.

Messagerie dans l'application (boîte, non-lus, gestes, échéance), chaque
personne ne lit que les siens. Échéances par le balayage existant, avec
rattrapage au démarrage et horloge injectable (route de dev). Migration
0008 additive : les signalements deviennent des « contredit », sans perte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:45:48 +01:00

334 lines
14 KiB
Rust

//! 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).
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:<index>`).
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> {
[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> {
[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<String>,
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
}
/// 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
}
/// The groups a fresh deployment starts with. Ordered as displayed.
pub fn default_groups() -> Vec<Group> {
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("essai", "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 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 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));
}
}
}