0041-pending-email-changes.md 109620 octets

RFC 0041 — Changement d'e-mail en deux temps (pending_email_changes)

  • Auteur : EBO (origine audit Gitrust / suite de la RFC 0038)
  • Date : 2026-09-19
  • Statut : Implemented (2026-10-01 ; Accepted 2026-09-19)
  • Apps consommatrices concernées : Gitrust ; rustwarden-ui (PUT /settings/profile, PUT /admin/users/{id}) ; tout consommateur headless qui appelle UserService::update_profile avec un e-mail
  • Crate cible : rustwarden-core (core uniquement)
  • Origine : § « v2 (follow-up) » de la RFC 0038 (L117-121), déclenchée par le mode de panne documenté dans docs/MIGRATION_GUIDE.md (§ RFC 0038, « Faute de frappe sur la nouvelle adresse = lock-out définitif sans recours self-service »)
  • Remplace : RFC 0038 — basculée en Superseded by 0041 (fait le 2026-09-19)

Arbitrages du mainteneur rendus le 2026-09-19. Les neuf Open questions sont tranchées (§ Open questions) et leurs conséquences sont intégrées au corps du document : méthode admin_force_change, page interstitielle GET → POST sur les deux liens, migration d'invalidate_on_email_change dans confirm_change, émission d'on_email_verified côté service, PendingEmailChangeCleanupProcessor avec rétention de 30 jours en constante.

Summary

Ne plus jamais écrire users.email avant preuve de possession : une demande de changement d'adresse atterrit dans une table pending_email_changes (token hashé + expiration), l'adresse du compte reste inchangée et vérifiée tant que le lien envoyé à la nouvelle boîte n'a pas été suivi et confirmé sur la page qu'il ouvre, et l'ancienne boîte reçoit une information assortie d'un lien de signalement. Le seul chemin qui écrit une adresse sans cette preuve est EmailChangeService::admin_force_change, réservé au rôle admin et tracé dans audit_logs.

Motivation

Problème concret

La v1 (RFC 0038, implémentée dans user_service.rs L277-312 + notify_email_change L327-419) écrit l'adresse immédiatement, puis bascule email_verified = false / email_verified_at = NULL.

Chaîne de panne, déjà décrite noir sur blanc dans le guide de migration :

  1. L'utilisateur saisit bob@gmial.com au lieu de bob@gmail.com.
  2. update_profile écrit la faute de frappe dans users.email et retire le flag vérifié.
  3. Le mail de validation part vers bob@gmial.com — boîte que l'utilisateur ne possède pas.
  4. L'ancienne adresse ne reçoit qu'une alerte account_modified sans lien d'action (user_service.rs L359-418, type de queue "account_modified").
  5. Les deux voies de secours (POST /api/v1/auth/resend-verification et .../by-username, montées dans auth/routes.rs L45-52) renvoient vers l'adresse stockée — donc vers la faute de frappe.
  6. Avec validation_email_required actif (défaut, RFC 0040), la prochaine connexion est refusée.

Résultat : lock-out sans recours self-service, correctible uniquement par un administrateur. Le coût de l'erreur est maximal alors que l'erreur elle-même est banale.

Le second mode de panne v1 est la session volée : un attaquant qui dispose d'un JWT valide peut réécrire users.email sans connaître le mot de passe. L'alerte part vers l'ancienne boîte, mais elle arrive après l'écriture, et sans moyen d'action.

Qui est impacté

  • Tout utilisateur qui change d'adresse via le profil ou via l'admin : un caractère de travers coûte un ticket support.
  • Les opérateurs : PUT /admin/users/{id} est aujourd'hui la seule procédure de déblocage.
  • Gitrust et les apps headless : elles héritent du comportement dès qu'elles appellent UserService::update_profile.
  • rustwarden-ui : son formulaire de profil (crates/rustwarden-ui/src/handlers/settings/profile.rs L24-51) poste username et email dans le même DTO.

Si on ne fait rien

On livre en 1.1.0 un durcissement sécurité (v1 : le flag vérifié ne suit plus l'adresse) dont l'effet de bord est une régression de disponibilité pour l'utilisateur légitime. Le guide de migration recommande déjà aux consommateurs d'ajouter une double saisie d'adresse côté UI : c'est du contournement applicatif dupliqué dans chaque consommateur, exactement ce que le framework est censé éviter.

Urgence

La v1 n'est pas diffusée : crates/rustwarden-core/Cargo.toml L3 est toujours version = "1.0.0", le train 1.1.0 du CHANGELOG.md n'est pas coupé. La fenêtre pour substituer 0041 à 0038 sans période de compatibilité est ouverte maintenant et se referme à la publication de 1.1.0. Aucun consommateur n'aura jamais vu le comportement v1.

Non-goals

  • Pas de refonte de l'UI — décision confirmée le 2026-09-19. rustwarden-ui reçoit la seule adaptation non cassante nécessaire (le champ e-mail du formulaire profil ne doit plus produire d'erreur serveur). La refonte UI complète du parcours — écran « demande en cours », bouton « annuler la demande », double saisie de l'adresse, appel du nouvel endpoint depuis le formulaire, écran de suivi côté admin — est un chantier séparé et ultérieur, explicitement hors périmètre. Corollaire assumé : entre le merge de cette RFC et ce chantier, le seul chemin utilisateur du changement d'adresse est l'appel API POST /api/v1/auth/email-change.
  • Pas de changement de signature de UserService::update_profile : elle reste (db, user_id, username: Option<String>, email: Option<String>). Seul le comportement de la branche e-mail change.
  • Pas de modification du flow register, et pas de traitement de la faute de frappe à l'inscription — tranché le 2026-09-19. create_user pose déjà email_verified: false et EmailValidationService::send_validation_email reste la porte de validation initiale. register écrit l'adresse saisie sans double vérification : un utilisateur qui se trompe au signup reste bloqué, et cette RFC ne change rien à cela. La justification du découpage : à l'inscription, le compte n'est pas encore établi et il n'existe aucune identité à protéger — c'est un flux de nature différente du changement d'adresse, où l'enjeu est précisément de ne pas perdre une identité déjà prouvée. L'arbitrage propre à ce flux (double saisie de l'adresse au formulaire d'inscription vs renvoi de lien sur demande, vs purge des comptes jamais validés) mérite sa propre RFC. Follow-up à ouvrir : « faute de frappe à l'inscription », suivi dans docs/plans/ au même titre que celui de cette RFC.
  • Pas de refonte de l'i18n des e-mails existants — tranché le 2026-09-19. Le // TODO: Get from user preferences or Accept-Language header de email_validation_service.rs L140 et de password_reset_service.rs L156 reste en place. Seuls les quatre nouveaux templates (les trois du flux utilisateur, plus celui de l'avis administrateur introduit par la décision n° 1) et les pages interstitielles lisent la locale du destinataire.
  • Pas de révocation des JWT d'accès déjà émis. confirm_change et admin_force_change révoquent les refresh tokens (même contrat que change_password_direct, user_service.rs L678, et admin_reset_password, L631) ; un access token non expiré reste valide jusqu'à son exp. Une révocation par jti via JwtBlacklistService est un chantier distinct.
  • Pas de charset e-mail (RFC 0036 couvre les usernames). La validation reste contains('@') && contains('.'), identique à l'existant.
  • Pas de blocage sur signalement. Décision explicite du mainteneur : report_suspicious alerte, il ne refuse pas. Voir § « Pourquoi le signalement ne bloque pas ».
  • Aucune nouvelle variable d'environnement — tranché le 2026-09-19. L'expiration réutilise EMAIL_VALIDATION_EXPIRATION_HOURS via config::tasks::email_validation_expiration_hours L98-100 (défaut 24 h) ; la rétention et la période du processeur de purge sont des constantes de code (§ « Rétention et tâche de fond »). Aucune clé n'est ajoutée à .env, donc aucun consommateur n'a de configuration à mettre à jour.
  • Pas de SQL brut : SeaORM uniquement, migration via SchemaManager.

API proposée

Les snippets ci-dessous sont la cible acceptée : ils ne compilent pas tant que la table, le modèle et le service n'existent pas, et l'implémentation peut ajuster les détails internes (noms de variables locales, découpage en helpers privés) tant que les signatures publiques et les contrats décrits ici sont respectés. Ils reprennent les patrons réels de EmailValidationService / PasswordResetService (vérifiés 2026-09-19, références de lignes dans le texte).

1. Schéma

Migration crates/rustwarden-core/src/migrations/m20260919_000011_pending_email_changes.rs, enregistrée dans migrations/mod.rs (pub mod L53 + entrée core_migrations() L69). Prochain numéro après m20260918_000010_totp_last_used_step.

ColonneTypeContraintes
iduuidPK
user_iduuidNOT NULL, FK → users.id ON DELETE CASCADE, indexée
new_emailvarchar(255)NOT NULL (même longueur que users.email)
token_hashvarchar(64)NOT NULL, UNIQUE, indexée (SHA-256 hex)
report_token_hashvarchar(64)NOT NULL, UNIQUE, indexée (SHA-256 hex, distinct du précédent)
expires_attimestamptzNOT NULL, indexée
used_attimestamptzNULL
reported_attimestamptzNULL
created_attimestamptzNOT NULL, DEFAULT now()

Patron de migration : m20260422_000007_create_personal_access_tokens.rs (FK cascade L71-77, string_len(64) pour un hash SHA-256 L39-44, index explicites L83-117). down() = drop_table, donc réversible.

Élargissement de chk_email_queue_email_type (découvert à l'exécution des tests, pas à la rédaction) : email_queue.email_type est encadré par une contrainte CHECK listant les valeurs admises, posée par m20260305_000001_initial_schema.rs L822-838. Les quatre types que cette RFC met en file (email_change_validation, email_change_notice, email_change_reported, email_change_by_admin) n'y figurent pas : sans élargissement, le tout premier INSERT du flux échoue avec une violation 23514 et request_change remonte une AppError::Database. La même migration reconstruit donc la contrainte avec les onze valeurs. Elle n'est pas supprimée : un email_type inconnu doit rester rejeté. Le down() ne resserre la liste que si plus aucune ligne n'utilise les nouveaux types (bloc DO $$), pour qu'un rollback ne détruise jamais d'historique d'envoi. SQL brut assumé ici : sea-query n'exprime pas de CHECK dans un ALTER TABLE, c'est déjà l'approche du schéma initial.

Pas d'index unique partiel « une seule demande pendante par utilisateur » : sea-query ne l'exprime pas portablement. L'unicité fonctionnelle est assurée applicativement par invalidate_previous_requests, exactement comme EmailValidationService::invalidate_previous_tokens (email_validation_service.rs L375-395).

Deux tokens distincts : le lien de confirmation part vers la nouvelle boîte, le lien de signalement vers l'ancienne. Un token unique partagé permettrait au détenteur de la nouvelle boîte de « signaler » à la place du titulaire, et au titulaire de confirmer sans posséder la nouvelle adresse. Deux secrets, deux capacités.

2. Modèle SeaORM

crates/rustwarden-core/src/models/pending_email_change.rs, déclaré dans models/mod.rs (patron email_validation_token L14 + re-export Entity L60).

/// PendingEmailChange entity model.
///
/// Holds an email-change request that has **not** been applied yet. Until the
/// confirmation is submitted, `users.email` keeps its current — verified —
/// value: the account is never left pointing at an unproven address.
///
/// # Security features
///
/// - Two independent SHA-256 hashes: `token_hash` (confirmation, mailed to the
///   new address) and `report_token_hash` (abuse report, mailed to the current
///   address). Plain tokens are never stored.
/// - Expiration: reuses `EMAIL_VALIDATION_EXPIRATION_HOURS` (24 h by default).
/// - Single use: `used_at` is set when the change is applied.
/// - Cascade delete: rows are removed when the user is deleted.
#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "pending_email_changes")]
pub struct Model {
    /// Unique identifier for the request.
    #[sea_orm(primary_key)]
    pub id: Uuid,

    /// User this request belongs to.
    #[sea_orm(indexed)]
    pub user_id: Uuid,

    /// Candidate address. Written to `users.email` only on confirmation.
    pub new_email: String,

    /// SHA-256 hash of the confirmation token (sent to `new_email`).
    #[sea_orm(unique, indexed)]
    pub token_hash: String,

    /// SHA-256 hash of the report token (sent to the current address).
    #[sea_orm(unique, indexed)]
    pub report_token_hash: String,

    /// Expiration timestamp.
    #[sea_orm(indexed)]
    pub expires_at: DateTime<Utc>,

    /// Set when the change has been applied. `None` while pending.
    pub used_at: Option<DateTime<Utc>>,

    /// Set when the current address owner flagged the request as suspicious.
    /// Informational only — it never blocks the change.
    pub reported_at: Option<DateTime<Utc>>,

    /// Creation timestamp.
    pub created_at: DateTime<Utc>,
}

impl Model {
    /// Returns `true` once the expiration timestamp has passed.
    pub fn is_expired(&self) -> bool {
        Utc::now() > self.expires_at
    }

    /// Returns `true` once the change has been applied.
    pub fn is_used(&self) -> bool {
        self.used_at.is_some()
    }

    /// Returns `true` while the request can still be confirmed.
    pub fn is_pending(&self) -> bool {
        !self.is_used() && !self.is_expired()
    }
}

Relation::User avec on_delete = "Cascade", à l'identique de email_validation_token.rs L54-70.

3. EmailChangeService

crates/rustwarden-core/src/services/email_change_service.rs, déclaré dans services/mod.rs (patron email_validation_service L22).

/// Default: 3 email-change requests per user per hour.
const EMAIL_CHANGE_RATE_LIMIT_MAX: u32 = 3;
const EMAIL_CHANGE_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600);

/// Process-wide sliding-window limiter for email-change requests.
///
/// Distinct from the password-reset and email-validation limiters so the three
/// flows keep separate budgets.
static EMAIL_CHANGE_LIMITER: OnceLock<RateLimitService> = OnceLock::new();

/// Two-step email change service.
///
/// Nothing is written to `users.email` until the confirmation link mailed to
/// the candidate address is followed. A typo therefore expires harmlessly and
/// the account never loses access.
pub struct EmailChangeService {
    email_config: EmailConfig,
    /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]).
    rate_limiter: RateLimitService,
    /// Optional translation backend. When `None`, emails fall back to
    /// [`config::tasks::default_locale`](crate::config::tasks::default_locale).
    i18n: Option<Arc<I18nService>>,
}

impl EmailChangeService {
    /// Create a service using the process-wide rate limiter.
    pub fn new(email_config: EmailConfig) -> Self { /* ... */ }

    /// Attach the translation service so emails honour the recipient locale.
    pub fn with_i18n(self, i18n: Arc<I18nService>) -> Self { /* ... */ }

    /// Build a service around an injected limiter (unit tests; avoids the
    /// process [`OnceLock`]).
    #[cfg(test)]
    pub(crate) fn new_with_limiter(
        email_config: EmailConfig,
        rate_limiter: RateLimitService,
    ) -> Self { /* ... */ }

    /// Open an email-change request for an authenticated user.
    ///
    /// Re-authentication is mandatory: a stolen session alone cannot move the
    /// account's identity. `current_password` is checked through
    /// [`AuthService::verify_credentials`](crate::services::auth_service::AuthService::verify_credentials),
    /// which runs bcrypt in constant time.
    ///
    /// On success two emails are queued:
    /// - a confirmation link to `new_email`;
    /// - a notice plus a report link to the account's **current** address.
    ///
    /// `users.email`, `email_verified` and `email_verified_at` are left
    /// untouched.
    ///
    /// # Errors
    ///
    /// - [`AppError::TooManyRequests`] — more than 3 requests for this user
    ///   within the hour.
    /// - [`AppError::InvalidCredentials`] — wrong `current_password`.
    /// - [`AppError::Validation`] — malformed address, address identical to the
    ///   current one, or address already taken by another account.
    /// - [`AppError::NotFound`] — unknown `user_id`.
    pub async fn request_change(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
        new_email: &str,
        current_password: &str,
    ) -> Result<(), AppError> { /* ... */ }

    /// Apply a pending change from its confirmation token.
    ///
    /// Confirming from the page the emailed link opens **is** the proof of
    /// possession (see the RFC's interstitial-page section), so the address lands
    /// already verified: `users.email = new_email`, `email_verified = true`,
    /// `email_verified_at = now()`. Uniqueness is re-checked here because
    /// another account may have claimed the address in the meantime.
    ///
    /// All refresh tokens are revoked (same contract as
    /// [`UserService::change_password_direct`](crate::services::user_service::UserService::change_password_direct)),
    /// and any pending password-change request is dropped through
    /// [`PasswordChangeService::invalidate_on_email_change`](crate::services::password_change_service::PasswordChangeService::invalidate_on_email_change):
    /// a confirmation link mailed to the previous address must never be usable
    /// once that address no longer belongs to the account.
    ///
    /// Hooks fired here, best-effort:
    /// [`RustwardenHooks::on_email_verified`] (the click *is* the proof of
    /// possession) and [`RustwardenHooks::on_audit_event`] with kind
    /// `user.email_changed`.
    ///
    /// Returns the user id whose address changed.
    ///
    /// # Errors
    ///
    /// - [`AppError::Validation`] — unknown, expired or already-used token, or
    ///   the candidate address is no longer available.
    /// - [`AppError::NotFound`] — the user was deleted since the request.
    pub async fn confirm_change(
        &self,
        db: &DatabaseConnection,
        token: &str,
        hooks: Option<&Arc<dyn RustwardenHooks>>,
    ) -> Result<Uuid, AppError> { /* ... */ }

    /// Administrative override: write `new_email` without confirmation.
    ///
    /// Operational recourse for the one dead end the two-step flow cannot
    /// solve on its own: a user who lost **both** the password and the access
    /// to the account's current mailbox (see the RFC's scenarios). The v1
    /// capability — an admin fixing an address through
    /// `PUT /admin/users/{id}` — is preserved, but it becomes an **explicit,
    /// audited operation** instead of a side effect of
    /// [`UserService::update_profile`](crate::services::user_service::UserService::update_profile).
    ///
    /// Contract:
    /// - `actor_id` **must** carry the `admin` role
    ///   ([`UserService::get_user_roles`](crate::services::user_service::UserService::get_user_roles));
    ///   otherwise [`AppError::Forbidden`] and nothing is written.
    /// - `users.email = new_email`, `email_verified = true`,
    ///   `email_verified_at = now()` — the operator vouches for the address
    ///   after an out-of-band identity check.
    /// - Every pending request of the target user is consumed, so a stale
    ///   confirmation link cannot resurrect the previous candidate address.
    /// - All refresh tokens of the **target** account are revoked, exactly as
    ///   in [`Self::confirm_change`]; the acting administrator's own sessions
    ///   are untouched.
    /// - An `audit_logs` row is written (`action = "email_changed_by_admin"`,
    ///   `level = "WARN"`, `user_id = actor_id`, `target_user_id = user_id`,
    ///   masked addresses) and [`RustwardenHooks::on_audit_event`] is emitted
    ///   with kind `user.email_changed_by_admin`.
    /// - Both mailboxes are notified: the previous address gets a notice, the
    ///   new one gets a confirmation of the operator-driven change. Neither
    ///   mail carries an action link — there is nothing left to confirm.
    ///
    /// No rate limit and no password check: authorisation is the admin role,
    /// enforced by the caller's route middleware **and** re-checked here.
    ///
    /// # Errors
    ///
    /// - [`AppError::Forbidden`] — `actor_id` is not an administrator.
    /// - [`AppError::Validation`] — malformed address, or address already
    ///   taken by another account.
    /// - [`AppError::NotFound`] — unknown `actor_id` or unknown `user_id`.
    pub async fn admin_force_change(
        &self,
        db: &DatabaseConnection,
        actor_id: Uuid,
        user_id: Uuid,
        new_email: &str,
        hooks: Option<&Arc<dyn RustwardenHooks>>,
    ) -> Result<(), AppError> { /* ... */ }

    /// Record that the current address owner flagged a request as suspicious.
    ///
    /// Writes an `audit_logs` row, emits
    /// [`RustwardenHooks::on_audit_event`] with kind `user.email_change_reported`
    /// and queues an alert to every administrator.
    ///
    /// **This never blocks the change.** A report is a signal for operators,
    /// not an authorisation gate: turning it into one would hand any reader of
    /// the old mailbox a denial-of-service lever over the account owner.
    ///
    /// Idempotent: a request already reported returns `Ok` without re-notifying.
    ///
    /// # Errors
    ///
    /// - [`AppError::Validation`] — unknown report token.
    pub async fn report_suspicious(
        &self,
        db: &DatabaseConnection,
        report_token: &str,
        hooks: Option<&Arc<dyn RustwardenHooks>>,
    ) -> Result<Uuid, AppError> { /* ... */ }

    /// Hard-delete rows expired for longer than
    /// `PENDING_EMAIL_CHANGE_RETENTION_DAYS` (30 days, a compile-time
    /// constant — no environment variable).
    ///
    /// Returns the number of deleted rows. Called by
    /// [`PendingEmailChangeCleanupProcessor`](crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor),
    /// and public so a consumer can drive it from its own cron instead. **The
    /// framework starts nothing by itself**: the consumer must call
    /// `.start()` on the processor (see the RFC's retention section).
    pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> { /* ... */ }

    /// Hash a token using SHA-256.
    fn hash_token(token: &str) -> String { /* ... */ }

    /// Mark every pending request of this user as consumed.
    async fn invalidate_previous_requests(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
    ) -> Result<(), AppError> { /* ... */ }
}

request_change — séquence

  1. self.rate_limiter.check_sliding_window(&format!("email_change:{user_id}"), EMAIL_CHANGE_RATE_LIMIT_MAX, EMAIL_CHANGE_RATE_LIMIT_WINDOW)? — check+record atomique, patron password_reset_service.rs L110-114. La clé est le user_id (pas l'adresse cible) : l'abus à limiter est « un compte qui spamme des boîtes tierces ».

  2. UserService::get_user_by_id(db, user_id)? → AppError::NotFound sinon.

  3. AuthService::verify_credentials(db, &user.email, current_password).await?. L'identifiant passé est l'adresse courante : elle contient '@', donc la branche e-mail de verify_credentials (auth_service.rs L55-65) est empruntée de façon déterministe, y compris pour les usernames hérités contenant un @. verify_credentials — et non authenticate_user — est utilisé volontairement : la politique validation_email_required de la RFC 0040 n'a pas à s'appliquer ici.

  4. Normalisation : let trimmed = new_email.trim();

    • !trimmed.contains('@') || !trimmed.contains('.') → AppError::Validation("Invalid email format") (message identique à celui de update_profile aujourd'hui, user_service.rs L280).
    • trimmed == user.email → AppError::Validation("New email address is identical to the current one").
    • UserService::get_user_by_email(db, trimmed)? renvoie un autre utilisateur → AppError::Validation(format!("Email '{trimmed}' already exists")).
  5. self.invalidate_previous_requests(db, user_id).await? — une demande active à la fois. Dans la foulée, purge opportuniste des lignes de cet utilisateur expirées depuis plus de PENDING_EMAIL_CHANGE_RETENTION_DAYS (§ « Rétention et tâche de fond »).

  6. Deux tokens Uuid::new_v4().to_string(), hashés par Self::hash_token (SHA-256 hex, patron email_validation_service.rs L368-372). expires_at = Utc::now() + Duration::hours(config::tasks::email_validation_expiration_hours()).

  7. INSERT de la ligne pending_email_changes.

  8. Deux EmailQueueService::enqueue (signature à 9 arguments, email_queue_service.rs L41-51) :

    • to_email = trimmed, type "email_change_validation", URL {email_base_url}/api/v1/auth/email-change/confirm/{token} ;
    • to_email = user.email, type "email_change_notice", URL {email_base_url}/api/v1/auth/email-change/report/{report_token}, plus l'adresse candidate affichée masquée (mask_email, aujourd'hui user_service.rs L16, à déplacer en pub(crate) dans un module partagé).

    Les deux URL sont des GET qui rendent une page et n'exécutent rien (§ « Page interstitielle ») : le texte des mails doit donc annoncer « cliquez, puis confirmez sur la page qui s'ouvre », et non « cliquez pour confirmer ».

Contrat sur SMTP. Contrairement à la v1, l'échec d'enqueue n'est pas best-effort : sans mail, la demande est inutilisable. Si EmailConfig::from_env() échoue, le handler ne construit même pas le service et renvoie AppError::Config. Si un enqueue échoue après l'INSERT, l'erreur remonte ; la ligne orpheline expire en 24 h et ne modifie rien. Ce choix est sûr précisément parce que rien n'a été écrit dans users.

confirm_change — séquence

  1. let token_hash = Self::hash_token(token); puis lookup par token_hash. Absent → AppError::Validation("Invalid email-change token").
  2. used_at.is_some() → Validation("This email-change link has already been used").
  3. is_expired() → Validation("This email-change link has expired").
  4. reported_at.is_some() → on continue. Le signalement ne bloque pas (§ dédiée).
  5. Re-contrôle d'unicité : UserService::get_user_by_email(db, &row.new_email)?. Si un autre utilisateur la détient → Validation("This email address is no longer available. Request the change again with a different address."), et la ligne n'est pas marquée used_at (la demande reste utilisable si l'adresse se libère avant expiration). Filet de sécurité : users.email porte une contrainte UNIQUE en base (m20260305_000001_initial_schema.rs L25-30) ; une violation concurrente renvoyée par SeaORM doit être traduite en ce même Validation et jamais propagée en AppError::Database (500).
  6. UPDATE users : email = new_email, email_verified = true, email_verified_at = Some(now), updated_at = now.
  7. UPDATE pending_email_changes : used_at = Some(now).
  8. RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await? (refresh_token_service.rs L354-380). Même politique que change_password_direct (user_service.rs L678) et admin_reset_password (L631) : l'identité de connexion vient de changer, toutes les sessions repassent par l'authentification.
  9. PasswordChangeService::invalidate_on_email_change(db, user_id).await (password_change_service.rs L331-357) — tranché le 2026-09-19 : l'appel migre de l'UI vers le core. Il était jusqu'ici émis par rustwarden-ui au moment de l'édition du profil (settings/profile.rs L55-68), c'est-à-dire au moment où la v1 écrivait l'adresse. Avec la v2, l'UI n'est plus dans la boucle à l'instant où l'adresse change réellement : le clic sur le lien de confirmation atterrit dans une route du core, souvent dans un autre navigateur que celui de la session qui a ouvert la demande. Laisser l'appel côté handler UI reviendrait donc à le perdre silencieusement — une demande de changement de mot de passe pendante, dont le lien a été envoyé à l'ancienne adresse, resterait confirmable après que cette adresse a cessé d'appartenir au compte. Erreur seulement loguée (tracing::warn!) : l'échec de la purge ne doit pas annuler un changement d'adresse déjà écrit.
  10. Ligne audit_logs (action = "email_changed", level = "INFO", message avec adresses masquées), patron auth_logging.rs L34-48.
  11. Hooks, dispatchés depuis le service (patron AuthLoggingService, qui écrit sa ligne d'audit et émet son AuditEvent dans la même méthode — auth_logging.rs L25-64), best-effort, erreurs seulement loguées. Tranché le 2026-09-19 : dispatch côté service et non côté handler, pour que tout consommateur headless qui monte sa propre route de confirmation (cf. § Routes, contrainte headless) reçoive les mêmes événements sans avoir à les ré-émettre :
    • hooks.on_email_verified(db, user_id) — l'ouverture du lien suivie de la confirmation sur la page est une preuve de possession, sémantiquement identique (et même légèrement plus forte, puisqu'elle exige une action humaine) au chemin verify_email (auth/handlers.rs L1221-1225). L'élargissement du contrat du hook est assumé et documenté : il signifie désormais « l'adresse du compte est prouvée à cet instant », quelle que soit la porte de preuve (validation initiale ou changement confirmé). Aucun consommateur existant ne casse : le hook était déjà émis après une preuve de possession, il l'est simplement sur un second chemin.
    • hooks.on_audit_event(db, AuditEvent { kind: "user.email_changed", actor_id: Some(user_id), target: Some(("user", user_id)), metadata }) — acteur et cible sont le même compte sur ce chemin libre-service, mais la cible est nommée quand même, pour qu'un consommateur qui route sur target voie ce chemin comme il voit user.email_changed_by_admin (cf. § Couverture réelle) (hooks.rs L38-48 pour la struct, L251-257 pour la méthode).

report_suspicious — séquence

  1. Lookup par report_token_hash. Absent → Validation("Invalid report token").
  2. reported_at.is_some() → Ok(user_id) immédiat, sans ré-envoi de mail. Cette idempotence reste nécessaire même avec la page interstitielle : un double clic sur le bouton, un rafraîchissement de la page de résultat ou un renvoi de formulaire par le navigateur produisent deux POST.
  3. Sinon reported_at = Some(now), même si la demande est expirée ou déjà consommée : le signal garde sa valeur pour l'exploitant.
  4. Ligne audit_logs : action = "email_change_reported", level = "WARN", target_user_id = Some(user_id).
  5. hooks.on_audit_event(db, AuditEvent { kind: "user.email_change_reported", .. }).
  6. UserService::get_all_admin_users(db)? (user_service.rs L467-492) puis un enqueue par administrateur, type "email_change_reported". Patron de notification admin existant : crates/rustwarden-ui/src/handlers/user_handler.rs L182-198.

Pourquoi le signalement ne bloque pas

Décision explicite du mainteneur, et elle se défend :

  • Un lien de signalement bloquant est une arme de déni de service. Toute personne qui lit l'ancienne boîte — collègue, ex-conjoint, employeur sur une adresse pro, passerelle anti-phishing qui pré-charge les URL — pourrait geler le changement du titulaire légitime, alors que celui-ci vient de prouver qu'il connaît le mot de passe.
  • Le cas d'usage dominant est précisément « je n'ai plus accès à l'ancienne boîte » (voir § scénarios). Bloquer sur une action de l'ancienne boîte reviendrait à en faire un second facteur obligatoire, ce qu'elle n'est pas.
  • Le blocage réel existe déjà en amont : le mot de passe. Le signalement couvre le scénario « mot de passe compromis », et dans ce scénario la bonne réponse opératoire est l'intervention admin (révocation, reset), pas un verrou automatique posé par un lien cliquable.
  • La pré-lecture automatique des liens rendrait le blocage non fiable : une passerelle qui GET le lien de signalement déclencherait le verrou sans intention humaine. Avec un signalement non bloquant, le pire cas d'une pré-lecture est une alerte admin en faux positif — coût nul. (Depuis la décision du 2026-09-19, le signalement passe de toute façon par une page interstitielle et un POST — cf. § Routes — donc la pré-lecture ne déclenche plus rien du tout ; l'argument reste comme défense en profondeur.)

admin_force_change — séquence et garde-fous

Tranché le 2026-09-19 : le recours administrateur est une méthode dédiée, pas un retour discret de l'écriture directe dans update_profile.

Le raisonnement : la v1 permettait à un opérateur de corriger une adresse via PUT /admin/users/{id} → update_profile. Cette RFC rend update_profile fail-loud sur tout changement d'adresse (§ 5), ce qui supprimerait la capacité opérationnelle si rien ne la remplaçait — or elle est indispensable au seul scénario que le flux en deux temps ne peut pas résoudre (mot de passe et ancienne boîte perdus, cf. § Scénarios). On la préserve donc, mais en la rendant explicite, autorisée et auditée au lieu d'un effet de bord d'une méthode de profil.

  1. UserService::get_user_roles(db, actor_id).await? (user_service.rs L184-207) ; si "admin" n'y figure pas → AppError::Forbidden, rien n'est écrit, aucun mail. La vérification est faite dans le service, en plus du middleware de route du consommateur : un appel headless direct au service ne doit pas court-circuiter l'autorisation. Acteur inconnu → AppError::NotFound.
  2. UserService::get_user_by_id(db, user_id)? → AppError::NotFound sinon. Auto-ciblage (actor_id == user_id) autorisé : un admin qui change sa propre adresse reste un admin agissant en tant qu'admin, et la trace d'audit le montre.
  3. Normalisation et contrôles identiques à request_change (format, unicité) — sauf le contrôle « adresse identique à l'actuelle », qui devient un no-op silencieux Ok(()) : PUT /admin/users/{id} renvoie tous les champs du formulaire, un écho de l'adresse courante ne doit pas échouer.
  4. invalidate_previous_requests(db, user_id) : toute demande pendante de la cible est consommée. Sans cela, un lien de confirmation encore valide pourrait, après coup, réécrire l'adresse que l'opérateur vient de poser.
  5. UPDATE users : email = new_email, email_verified = true, email_verified_at = Some(now), updated_at = now. Le flag vérifié est posé parce que l'opérateur engage sa vérification hors bande ; c'est exactement ce que fait déjà admin_reset_password avec le mot de passe (user_service.rs L613-635).
  6. RefreshTokenService::revoke_all_tokens_for_user(db, user_id) — sessions de la cible uniquement. Si l'opérateur intervient précisément parce que le compte est compromis, laisser vivre les sessions existantes viderait l'opération de son sens.
  7. PasswordChangeService::invalidate_on_email_change(db, user_id) — même motif qu'en confirm_change.
  8. Ligne audit_logs : action = "email_changed_by_admin", level = "WARN" (et non INFO : une écriture d'identité sans preuve de possession mérite d'être remontée), user_id = Some(actor_id), target_user_id = Some(user_id), message avec adresses masquées.
  9. hooks.on_audit_event(db, AuditEvent { kind: "user.email_changed_by_admin", actor_id: Some(actor_id), target: Some(("user", user_id)), metadata }). on_email_verified n'est pas émis : il n'y a eu aucune preuve de possession de l'adresse par son titulaire, et le contrat du hook (« l'adresse est prouvée ») serait faux. C'est la différence de fond avec confirm_change.
  10. Deux enqueue, sans lien d'action (il n'y a plus rien à confirmer) : un avis à l'ancienne adresse (« votre adresse de connexion a été modifiée par un administrateur ») et un avis à la nouvelle. Type de queue "email_change_by_admin". Contrairement à request_change, l'échec d'enqueue est ici best-effort et seulement logué : l'écriture est déjà faite et l'opérateur, contrairement à l'utilisateur, a un retour synchrone de l'opération.

Ce que la méthode ne fait pas : aucun rate limit (l'autorisation est le rôle, pas un budget), aucune vérification du mot de passe de l'acteur (le middleware d'admin du consommateur décide de sa propre politique de ré-authentification), aucune restauration possible en un clic — un retour arrière est un second admin_force_change, tracé comme tel.

4. Routes

Toutes sur le canal machine (auth/routes.rs, monté via merge_machine_routes, jamais derrière la couche CSRF — cf. rustdoc L11-23).

MéthodeCheminAuthGroupe de routeur
POST/api/v1/auth/email-changeAuthUser (Bearer)limited
GET/api/v1/auth/email-change/confirm/{token}public (lien de mail)unlimited
POST/api/v1/auth/email-change/confirm/{token}public (soumission du formulaire)unlimited
GET/api/v1/auth/email-change/report/{token}public (lien de mail)unlimited
POST/api/v1/auth/email-change/report/{token}public (soumission du formulaire)unlimited

Corps du POST /email-change : { "new_email": "...", "current_password": "..." }. Réponse 200 { "message": "...", "pending_email": "<masqué>" }.

Page interstitielle : GET → bouton → POST sur les deux liens

Tranché le 2026-09-19 : aucun GET n'a d'effet de bord. Les deux liens envoyés par e-mail — confirmation et signalement — pointent vers un GET qui se contente de rendre une page HTML contenant un formulaire POST ; c'est le clic humain sur le bouton de cette page qui appelle le service. D'où quatre routes au lieu de deux.

RouteEffet
GET .../confirm/{token}Rend la page « Confirmer le changement d'adresse ». Aucune écriture. La demande n'est pas consommée, users.email n'est pas touché.
POST .../confirm/{token}Appelle EmailChangeService::confirm_change et rend la page de résultat.
GET .../report/{token}Rend la page « Signaler cette demande ». Aucune écriture. reported_at reste NULL.
POST .../report/{token}Appelle EmailChangeService::report_suspicious et rend la page de résultat.

Motif : aucun préchargement de passerelle anti-phishing ne peut déclencher l'action. Les passerelles de sécurité mail (Defender, Proofpoint, Barracuda…) et les clients qui génèrent un aperçu de lien émettent des GET, jamais des POST de formulaire. Le flux existant verify-email/{token} reste, lui, un GET agissant — c'est une dette connue et non traitée ici (elle ne relève pas de cette RFC), mais le nouveau flux ne la réplique pas : le changement d'adresse écrit l'identité de connexion du compte, il mérite une preuve d'intention humaine et pas seulement une preuve de joignabilité de la boîte.

Le GET du lien de confirmation pré-valide quand même le token pour afficher un message utile (« ce lien a expiré », « ce lien a déjà été utilisé ») ; cette lecture est en lecture seule. L'idempotence de report_suspicious (§ dédiée) reste implémentée : elle ne protège plus d'un préchargement, mais d'un double clic ou d'un rafraîchissement de page.

Pas de couche CSRF, et ce n'est pas un trou : ces routes vivent sur le canal machine (merge_machine_routes), hors CSRF par construction. Un POST inter-site forgé n'apporte rien à un attaquant, puisqu'il lui faudrait déjà connaître le token (UUID v4, 122 bits, envoyé dans une boîte qu'il ne lit pas) — et s'il le connaît, il peut appeler la route directement.

Contrainte headless : la page ne dépend ni d'Askama ni de rustwarden-ui

rustwarden-core ne doit jamais exiger une UI pour fonctionner. La page interstitielle et la page de résultat sont donc du HTML minimal autoportant, construit dans le handler du core :

  • aucune dépendance à rustwarden-ui (qui est optionnel et peut ne pas être monté) ;
  • aucun template Askama, aucun fichier dans templates/, donc rien à charger depuis le disque au runtime et aucune erreur AppError::Template possible sur ce chemin ;
  • un String HTML construit par format! dans le handler, renvoyé en Html<String> avec Content-Type: text/html; charset=utf-8 ; structure minimale (<!DOCTYPE html>, un titre, un paragraphe, un <form method="post" action="…"> avec un unique <button>), style inline réduit au strict lisible, aucun JavaScript, aucune ressource externe (pas de CSS ni de police distante — la page doit s'afficher correctement dans un navigateur sans réseau vers le CDN et ne doit pas violer la CSP du consommateur) ;
  • libellés traduits via l'I18nService quand il est injecté, repli sur les chaînes embarquées sinon — même contrat que les templates d'e-mail (§ 7) ;
  • l'action du formulaire est un chemin relatif (./, c'est-à-dire la même URL en POST), pour que la page reste correcte quel que soit le préfixe sous lequel le consommateur a monté les routes.

Surchargeable. Un consommateur qui veut sa propre présentation monte sa propre route GET sur le même chemin (ou sur un chemin à lui, en surchargeant l'URL des e-mails via email_base_url) et rend ce qu'il veut, du moment que son formulaire poste vers la route POST du core. Le point d'extension est la route, pas un trait de rendu : c'est le patron déjà utilisé dans le dépôt (le core expose une route par défaut, le consommateur la remplace dans son routeur) et il évite d'introduire une abstraction de templating dans rustwarden-core.

Ce que remplace cette décision. Le Draft envisageait un EmailChangeResultTemplate Askama, frère de email_verified.html (patron EmailVerifiedTemplate, auth/handlers.rs L208-229). Cette piste est abandonnée : elle lie le chemin de confirmation à la présence d'un template sur disque, ce qui est exactement ce qu'un consommateur headless ne veut pas.

Rate limit : le POST /api/v1/auth/email-change doit-il passer sous auth_ip_rate_limit ?

Oui — retenu. Justification :

  • La couche auth_ip_rate_limit L223-229 est par IP, 10 requêtes/minute par défaut (DEFAULT_AUTH_MAX_PER_MINUTE, L85 ; surchargeable via RATE_LIMIT_AUTH_PER_MINUTE). Elle est déjà appliquée au groupe limited de auth/routes.rs (L53-55).
  • Le POST déclenche un bcrypt par appel (verify_credentials). C'est un endpoint coûteux en CPU : il appartient à la même famille que /login et /2fa/verify, tous deux dans limited.
  • Le limiteur par utilisateur du service (3/h) protège la boîte cible ; le limiteur par IP protège le serveur. Les deux sont complémentaires, pas redondants.
  • Coût : tous les utilisateurs derrière un même NAT partagent 10 req/min. Pour un endpoint utilisé quelques fois dans la vie d'un compte, c'est sans conséquence pratique.

Les quatre routes de lien (les deux GET et les deux POST) restent hors couche IP, dans unlimited, comme verify-email/{token} aujourd'hui (L64-67). Trois raisons :

  • le brute-force de token est hors d'atteinte (UUID v4 → 122 bits d'entropie), donc le limiteur n'apporterait aucune sécurité ;
  • ces routes ne font aucun bcrypt — le coût serveur est un SELECT indexé pour les GET, quelques UPDATE pour les POST. Seul POST .../report/{token} met des mails en file (un par administrateur), et il faut déjà détenir le token de signalement pour l'atteindre ; l'idempotence de report_suspicious borne d'ailleurs l'envoi à un seul lot par demande, quel que soit le nombre d'appels ;
  • un limiteur par IP pénaliserait les passerelles mail mutualisées et les grands NAT d'entreprise, qui présentent des milliers d'utilisateurs derrière une seule adresse.

Le seul POST protégé par le budget IP est donc POST /api/v1/auth/email-change (ouverture de demande), qui est aussi le seul à déclencher un bcrypt et deux envois de mail.

5. UserService::update_profile — fail-loud

La branche e-mail v1 (user_service.rs L277-301) et notify_email_change (L327-419) sont supprimées. À la place :

if let Some(new_email) = &email {
    let trimmed = new_email.trim();
    // Echoing the current address stays a no-op: profile forms submit every
    // field, so an unchanged value must not be treated as a change request.
    if trimmed != user.email.as_str() {
        return Err(AppError::Validation(
            "Email changes are not handled by update_profile; call \
             EmailChangeService::request_change (POST /api/v1/auth/email-change) \
             — the address is only written once the change is confirmed from \
             the link mailed to it."
                .to_string(),
        ));
    }
}

Fail-loud, pas d'ignorance silencieuse : un consommateur qui continue d'envoyer une nouvelle adresse à update_profile obtient une 400 explicite pointant vers la bonne API, au lieu d'un succès mensonger où l'adresse ne bouge pas. La signature reste identique, donc tout code consommateur compile sans modification.

Cas administrateur. PUT /admin/users/{id} passe aujourd'hui par le même update_profile et tombe donc sous la même erreur. Ce n'est pas une perte de capacité : le chemin opérateur devient EmailChangeService::admin_force_change (§ 3), qui fait explicitement — et en le traçant — ce que l'ancien chemin faisait par effet de bord. Le message d'erreur d'update_profile reste volontairement générique et pointe vers EmailChangeService : c'est le point d'entrée unique, la méthode exacte dépend de qui appelle.

Effets de bord à traiter dans le même patch :

  • mask_email (user_service.rs L16) n'a plus d'appelant dans user_service → déplacer en helper pub(crate) consommé par EmailChangeService (le test mask_email_never_leaks_full_address, L1177, suit).
  • La § « Email revalidation (RFC 0038) » du rustdoc de update_profile (L213-239) est remplacée par le renvoi vers EmailChangeService.

6. Adaptation minimale de rustwarden-ui (non cassante)

crates/rustwarden-ui/src/handlers/settings/profile.rs poste username et email (L24-29, L50-51). Le formulaire renverrait donc l'adresse inchangée à chaque enregistrement : couvert par la branche no-op ci-dessus, aucune erreur. Trois retraits suffisent :

  • supprimer le calcul email_was_changed (L41-48) et l'appel à PasswordChangeService::invalidate_on_email_change (L55-68) — il n'y a plus de changement d'e-mail à ce point du flux. L'appel n'est pas supprimé, il est déplacé (tranché le 2026-09-19) : il vit désormais dans EmailChangeService::confirm_change (étape 9) et dans admin_force_change (étape 7), c'est-à-dire aux deux seuls endroits où users.email change réellement. Ce déplacement est la raison même de la décision : avec la v2, l'UI n'est plus dans la boucle à cet instant, garder l'appel côté handler le perdrait silencieusement ;
  • ne plus transmettre request.email à update_profile, ou le transmettre tel quel (no-op) — au choix, les deux sont sûrs ;
  • mettre à jour les commentaires // RFC 0038 (L43, L70-74).

Tout le reste — écran « demande en cours », bouton d'annulation, double saisie, appel au nouvel endpoint depuis le formulaire — est hors scope et fera l'objet d'un travail UI dédié.

7. Templates d'e-mail et locales

Quatre templates Askama dans crates/rustwarden-core/templates/emails/, sur le patron de email_validation.html / EmailValidationTemplate (email_templates.rs L377-494). Askama est ici légitime et sans conflit avec la contrainte headless de la § Routes : les e-mails sont produits par le service, pas par une route HTTP, et le framework rend déjà ses mails ainsi.

FichierStructDestinataireContenu
email_change_validation.htmlEmailChangeValidationTemplatenouvelle adresselien vers la page de confirmation, durée de validité, « si vous n'êtes pas à l'origine de cette demande, ignorez ce message »
email_change_notice.htmlEmailChangeNoticeTemplateadresse couranteadresse candidate masquée, rappel que le compte n'a pas changé, lien vers la page de signalement
email_change_reported_admin.htmlEmailChangeReportedAdminTemplateadministrateursusername, adresses masquées, horodatage, rappel que le changement n'est pas bloqué
email_change_by_admin.htmlEmailChangeByAdminTemplateancienne et nouvelle adresseadresses masquées, mention explicite « modification effectuée par un administrateur », aucun lien d'action (il n'y a rien à confirmer ni à signaler), invitation à contacter le support en cas de doute

Le quatrième template est la conséquence directe de la décision n° 1 : admin_force_change doit prévenir les deux boîtes. Il porte un seul corps, rendu deux fois (une fois par destinataire) ; c'est le même contenu pour les deux, puisque les deux personnes — s'il y en a deux — ont besoin de la même information.

Les libellés des liens doivent refléter le schéma GET → page → POST : « Ouvrir la page de confirmation », pas « Confirmer » ; « Ouvrir la page de signalement », pas « Signaler ».

Six locales. Les templates d'e-mail existants codent fr/en en dur (branches {% if locale == "fr" %} et render_text() à deux branches, L444-492) tandis que les sujets sont choisis par un match à quatre branches (fr/de/es/_, email_validation_service.rs L159-165). Les nouveaux templates ne reproduisent pas cette dette :

  • nouvelles clés sous une section email_change: dans les six fichiers crates/rustwarden-core/locales/{fr,en,de,es,it,pt}.yml : sujets, titre, salutation, corps, libellé de bouton, mention d'expiration, pied de page des quatre e-mails, plus les libellés des pages interstitielles et des pages de résultat des deux liens (titre, question posée, libellé du bouton, messages de succès et d'échec). Bien les six de rustwarden-core, pas ceux de rustwarden-ui : le service, les templates et les routes vivent dans le core, et le périmètre de cette RFC est core-only. Les deux jeux de fichiers existent en parallèle (crates/rustwarden-core/locales/ pour le framework, crates/rustwarden-ui/locales/ pour les écrans) ;
  • les structs de template reçoivent des champs déjà traduits, patron ProfileTemplate de rustwarden-ui (settings/profile.rs L250-263, chaque champ alimenté par i18n_service.translate(...)) ;
  • la locale du destinataire vient de user_preferences.language (crates/rustwarden-core/src/models/user_preferences.rs L22, lue via UserPreferencesService::get_preferences), avec repli sur config::tasks::default_locale() L150-152 ;
  • sans I18nService injecté (EmailChangeService::new sans with_i18n), les templates et les pages interstitielles retombent sur les chaînes de repli embarquées dans le code — le service et ses routes restent utilisables en headless sans aucune plomberie i18n ni fichier de locale.

Dette existante à ne pas répliquer : crates/rustwarden-core/locales/de.yml et es.yml (67 lignes) n'ont pas la section password_change_confirmation: présente dans fr/en/it/pt. La section email_change: doit être ajoutée aux six fichiers, sans exception.

8. Rétention et tâche de fond

Tranché le 2026-09-19 : un PendingEmailChangeCleanupProcessor en bonne et due forme, calqué sur PersonalTokenCleanupProcessor (L18-50), plus une méthode publique cleanup_expired. Le Draft recommandait de ne pas ajouter de processeur, au motif que le framework n'en démarre aucun de son propre chef. L'arbitrage retient l'inverse : le fait que app.rs ne démarre pas les processeurs de purge est une propriété du câblage, pas une raison de ne pas fournir la primitive planifiable. Le consommateur qui veut la purge ne doit pas avoir à réécrire une boucle tokio::interval que le framework sait déjà écrire.

  1. Processeur crates/rustwarden-core/src/tasks/pending_email_change_cleanup.rs, déclaré dans tasks/mod.rs et re-exporté dans le prelude (lib.rs, à côté de PersonalTokenCleanupProcessor L66) :

    /// Background processor that hard-deletes `pending_email_changes` rows
    /// expired for longer than the retention window.
    ///
    /// Mirrors [`PersonalTokenCleanupProcessor`](crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor):
    /// **the framework never starts it**. `app.rs` leaves it alone on purpose —
    /// the consumer owns its scheduling policy and calls `.start()` if it wants
    /// the purge. Nothing breaks when it is never started: rows simply
    /// accumulate, and the opportunistic purge below keeps the common case
    /// bounded.
    pub struct PendingEmailChangeCleanupProcessor {
        db: Arc<DatabaseConnection>,
    }
    
    impl PendingEmailChangeCleanupProcessor {
        /// Create a processor bound to the given database connection.
        pub fn new(db: Arc<DatabaseConnection>) -> Self { /* ... */ }
    
        /// Spawn the cleanup loop. First tick fires immediately, then every
        /// `PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS` (1 h).
        pub fn start(self) { /* tokio::spawn + interval, patron L35-49 */ }
    }
    
  2. EmailChangeService::cleanup_expired(db) public — c'est elle que le processeur appelle, et elle reste utilisable seule par un consommateur qui préfère un cron système, une tâche applicative ou un DELETE déclenché à la main. Le processeur n'est donc qu'un emballage de planification, ce qui le rend testable indépendamment de la primitive.

  3. Rétention : const PENDING_EMAIL_CHANGE_RETENTION_DAYS: i64 = 30; — une constante de code, pas de variable d'environnement (cf. Non-goals). Prédicat de purge : expires_at < now() - 30 jours, quel que soit l'état de la ligne (consommée, signalée, ou simplement périmée) — passé ce délai, la ligne n'a plus aucune valeur opérationnelle, la trace durable étant dans audit_logs.

  4. Période du processeur : constante également, const PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS: u64 = 3600;. C'est la seule divergence assumée avec PersonalTokenCleanupProcessor, qui lit PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS via config::tasks : la décision « aucune nouvelle variable d'environnement » prime, et la période d'une purge à horizon 30 jours n'a aucune raison d'être réglée finement.

  5. Purge opportuniste conservée dans request_change : avant l'INSERT, supprimer les lignes de cet utilisateur dont expires_at < now() - RETENTION. Coût : un DELETE indexé sur un chemin déjà rare (3/h par utilisateur au maximum). Elle garantit que la rétention est respectée pour les comptes actifs même chez un consommateur qui ne démarre jamais le processeur — ceinture et bretelles, sans lesquelles la décision de ne pas auto-démarrer la tâche laisserait des PII s'accumuler chez les consommateurs distraits.

À documenter explicitement, exactement comme pour les processeurs existants : app.rs ne démarre pas PendingEmailChangeCleanupProcessor. Le point est à écrire dans le rustdoc du processeur, dans le MIGRATION_GUIDE.md et à proposer en commentaire dans le scaffold du CLI (tools/rustwarden-cli/src/templates.rs L358-364, à côté de PersonalTokenCleanupProcessor) :

// Purge des demandes de changement d'e-mail expirees (> 30 jours).
// Le framework ne la demarre pas : decommentez pour l'activer.
// PendingEmailChangeCleanupProcessor::new(db.clone()).start();

Pourquoi purger plutôt que conserver. new_email est une donnée personnelle en clair. La valeur d'audit est préservée ailleurs et durablement : confirm_change et report_suspicious écrivent chacun une ligne audit_logs (adresses masquées), et c'est audit_logs — pas une table de travail — qui est le magasin d'audit du framework. Conserver indéfiniment des adresses candidates non confirmées serait une accumulation de PII sans contrepartie.

Usage consommateur

use rustwarden_core::config::email::EmailConfig;
use rustwarden_core::error::AppError;
use rustwarden_core::services::email_change_service::EmailChangeService;
use sea_orm::DatabaseConnection;
use uuid::Uuid;

/// Open an email-change request. Returns `Ok(())` once both emails are queued;
/// `users.email` is unchanged until the change is confirmed from the page the
/// emailed link opens.
async fn request_email_change(
    db: &DatabaseConnection,
    user_id: Uuid,
    new_email: &str,
    current_password: &str,
) -> Result<(), AppError> {
    // `EmailConfig::from_env` returns `Result<Self, String>` (config/email.rs L119).
    let email_config = EmailConfig::from_env().map_err(AppError::Config)?;
    EmailChangeService::new(email_config)
        .request_change(db, user_id, new_email, current_password)
        .await
}

Recours opérateur, après vérification d'identité hors bande :

use rustwarden_core::services::email_change_service::EmailChangeService;

/// Force an address as an administrator. Writes `users.email` immediately,
/// revokes the target's sessions and leaves an `email_changed_by_admin` audit
/// trail. `actor_id` must hold the `admin` role, or the call fails with
/// `AppError::Forbidden`.
async fn admin_fix_email(
    db: &DatabaseConnection,
    actor_id: Uuid,
    user_id: Uuid,
    new_email: &str,
) -> Result<(), AppError> {
    let email_config = EmailConfig::from_env().map_err(AppError::Config)?;
    EmailChangeService::new(email_config)
        .admin_force_change(db, actor_id, user_id, new_email, None)
        .await
}

Côté update_profile, le changement visible pour un consommateur :

use rustwarden_core::services::user_service::UserService;

// Before RFC 0041: wrote users.email and cleared the verified flags.
// After RFC 0041: returns AppError::Validation pointing at EmailChangeService.
let err = UserService::update_profile(db, user_id, None, Some("new@example.com".into()))
    .await
    .unwrap_err();

// A username-only rename is unaffected.
let user = UserService::update_profile(db, user_id, Some("newname".into()), None).await?;

Analyse de compatibilité

Checklist :

  • API existante non modifiée → les signatures de UserService::update_profile, EmailValidationService, RefreshTokenService sont inchangées. Tout consommateur compile sans retouche.
  • API existante étendue avec défaut → nouveaux items publics par ajout pur : models::pending_email_change, services::email_change_service::EmailChangeService (dont admin_force_change et cleanup_expired), tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor, quatre templates d'e-mail, cinq routes dans auth_api_routes() (POST /email-change, puis GET + POST sur confirm/{token} et sur report/{token}).
  • API existante modifiée ou supprimée → breaking comportemental : update_profile renvoie désormais AppError::Validation au lieu d'écrire une nouvelle adresse. Pas de rupture de compilation, rupture d'exécution volontaire et explicite.
  • Nouveau comportement par défaut → oui, sans feature flag (c'est un correctif de sécurité et de disponibilité). Aucun consommateur ne peut « rester en v1 » : la v1 n'a jamais été publiée.
  • Changement de schéma DB → migration m20260919_000011_pending_email_changes, up = create_table + index, down = drop_table → réversible, sans réécriture de table existante.
  • Changement de format sérialisé → non. Le JSON du profil (profile.rs L121-125) garde les mêmes clés ; après RFC 0041 email_verified ne bascule plus à false sur un changement — un consommateur qui affichait « vérification en attente » après édition du profil n'a simplement plus ce cas.

Par rapport à la v1 (RFC 0038) — différentiel de comportement

Sujetv1 (RFC 0038, non diffusée)v2 (cette RFC)
Écriture de users.emailimmédiateseulement après confirmation explicite sur la page ouverte par le lien
email_verified pendant l'attentefalse (compte dégradé)true (compte intact)
Faute de frappelock-out définitif, recours admin obligatoirela demande expire, le compte n'a jamais bougé
Session volée (sans mot de passe)changement appliquérefusé : re-auth mot de passe obligatoire
Ancienne adressealerte sans actioninformation + lien de signalement
Sessions après changementconservéesrefresh tokens révoqués
Point d'entrée utilisateurUserService::update_profileEmailChangeService::request_change
Recours administrateurPUT /admin/users/{id} → écriture directe, effet de bord d'update_profile, sans trace dédiéeEmailChangeService::admin_force_change — explicite, réservé au rôle admin, tracé dans audit_logs + on_audit_event, sessions de la cible révoquées
Déclenchement du changementGET sur le lien de validation (préchargeable par une passerelle)page interstitielle + POST : aucun GET n'écrit
Purge des demandes de changement de mot de passeappel invalidate_on_email_change côté UI, à l'édition du profilappel côté core, dans confirm_change et admin_force_change
Schémainchangéune table + un processeur de purge (non démarré par défaut)

Impact sur les RFC voisines

  • RFC 0040 (authenticate_user honore validation_email_required) : compatible, et même complémentaire. Une adresse confirmée arrive email_verified = true, donc aucun blocage de login n'est introduit. Le couplage « changement d'e-mail ⇒ prochain login refusé » décrit dans MIGRATION_GUIDE.md disparaît.
  • RFC 0036 (Username) : intacte. Le rename de username passe toujours par Username::parse (user_service.rs L261) sur un vrai rename uniquement.
  • RFC 0037 / 0039 : sans interaction.

Alternatives considérées

Alternative A (retenue, acceptée le 2026-09-19) : table pending_email_changes + service dédié + double notification

Pros

  • Supprime la classe entière des lock-out par changement d'adresse : rien n'est écrit tant que la possession n'est pas prouvée. Une faute de frappe expire en 24 h sans la moindre conséquence.
  • Exige le mot de passe : une session volée seule ne déplace plus l'identité du compte.
  • L'adresse arrive vérifiée : plus de fenêtre email_verified = false, donc plus d'interaction négative avec la RFC 0040.
  • L'ancienne boîte reçoit un moyen d'agir (signalement), pas seulement un constat.
  • Point d'entrée unique et nommé (EmailChangeService) : tous les consommateurs convergent, la logique ne se duplique plus dans chaque UI.
  • Révocation des refresh tokens alignée sur le contrat déjà en vigueur pour les changements de mot de passe.

Cons

  • La RFC la plus lourde du train, et les arbitrages du 2026-09-19 l'ont alourdie : migration + modèle + service (dont admin_force_change) + 5 routes + pages interstitielles HTML sans templating + 4 templates d'e-mail × 6 locales + processeur de purge + retrait du code v1.
  • Trois étapes visibles pour l'utilisateur (demande, clic sur le lien, confirmation sur la page) au lieu d'une ; l'UI actuelle n'en expose aucune (d'où l'adaptation minimale + un chantier UI ultérieur). La troisième étape est le prix payé pour l'immunité au préchargement des liens.
  • Une table de plus à faire vivre (rétention, PII) et une tâche de fond de plus dans le catalogue — non démarrée par défaut, donc un point de documentation supplémentaire.
  • Un administrateur ne peut plus corriger une adresse via update_profile : il doit appeler admin_force_change. C'est un changement d'appel pour les consommateurs qui exposaient PUT /admin/users/{id} — le coût réel de la décision n° 1, assumé en échange d'une capacité tracée au lieu d'un effet de bord.

Alternative B : garder la v1 et ajouter un simple « annuler » dans l'alerte à l'ancienne adresse

L'adresse est écrite immédiatement, mais le mail à l'ancienne boîte porte un lien de rollback qui restaure l'adresse précédente et remet email_verified = true.

Pros : diff plus petit (une table de rollback ou une colonne previous_email), conserve le flux update_profile actuel, ferme le scénario « session volée » a posteriori.

Cons : ne corrige pas le cas dominant, la faute de frappe — l'ancienne boîte est souvent celle que l'utilisateur ne consulte plus, et le lien de rollback y part quand même. Entre l'écriture et le rollback, le compte est en email_verified = false : reset password et login pointent vers une adresse non prouvée. Introduit un état « adresse précédente » à maintenir, avec ses propres questions d'expiration. On paie une complexité comparable pour une garantie strictement plus faible.

Alternative C (ne rien faire / garder la v1 telle quelle)

Pros : zéro coût, zéro risque immédiat, la v1 est déjà écrite, testée et documentée (CHANGELOG.md, MIGRATION_GUIDE.md, SERVICES_REFERENCE.md). Elle ferme bien le trou d'origine (le flag vérifié ne suit plus l'adresse).

Cons : on publie sciemment un mode de panne que notre propre guide de migration qualifie de « lock-out définitif sans recours self-service », avec pour seule parade une recommandation de double saisie à réimplémenter dans chaque consommateur. La fenêtre de substitution sans compatibilité ascendante (1.1.0 non coupée) se referme définitivement. Le scénario « session volée » reste ouvert. Dette garantie : la v2 devra être faite un jour, mais alors avec une migration de comportement pour les consommateurs déjà en production.

Alternative D : déléguer entièrement aux consommateurs (helper documenté, pas de service)

Le framework refuserait le changement d'e-mail et publierait un « recipe » dans la doc ; chaque app (Gitrust, rustwarden-ui, headless) implémenterait sa table et ses mails.

Pros : zéro surface d'API nouvelle dans rustwarden-core, zéro migration imposée, chaque app choisit sa politique.

Cons : contraire à la raison d'être du framework — l'authentification et la gestion d'identité sont exactement ce que rustwarden-core centralise. Garantit la divergence entre consommateurs sur une primitive de sécurité, et donc des trous de sécurité inégaux. Multiplie par N le coût des 6 locales et des templates. Rend impossible un audit centralisé (audit_logs, on_audit_event).

Scénarios explicités

« J'ai perdu l'accès à mon ancienne boîte »

C'est le cas d'usage principal, et il fonctionne. L'utilisateur est connecté (ou peut se connecter : son adresse courante est toujours email_verified = true, la RFC 0040 ne le bloque pas). Il connaît son mot de passe, donc request_change passe. Le lien de confirmation part vers la nouvelle boîte — celle qu'il possède. Il l'ouvre, confirme sur la page qui s'affiche, l'adresse change.

L'information envoyée à l'ancienne adresse part dans le vide : elle rebondit ou reste non lue. Aucune conséquence : elle n'est ni un accusé de réception requis, ni un second facteur. Un bounce détecté par le service IMAP n'a pas à empêcher la confirmation.

« J'ai perdu l'accès à mon ancienne boîte et j'ai oublié mon mot de passe »

Impasse self-service, par construction et volontairement. Le flux de reset (PasswordResetService::request_reset, password_reset_service.rs L102) envoie le lien vers l'adresse stockée, c'est-à-dire l'ancienne. Sans mot de passe et sans ancienne boîte, il ne reste aucun facteur à prouver : accorder le changement reviendrait à donner le compte à quiconque le demande.

Recours : l'administrateur, après vérification hors bande de l'identité. C'est le même contrat que pour n'importe quel compte sans facteur récupérable. Le chemin est désormais tranché et nommé (décision n° 1 du 2026-09-19) : EmailChangeService::admin_force_change(db, actor_id, user_id, new_email, hooks), réservée au rôle admin, qui écrit l'adresse sans confirmation, révoque les sessions de la cible et laisse une ligne audit_logs email_changed_by_admin (§ « admin_force_change — séquence et garde-fous »). Ce chemin doit être documenté dans le MIGRATION_GUIDE.md : c'est la question n° 1 que posera le support.

Course sur la même adresse cible

Alice et Bob demandent tous deux shared@example.com. Les deux demandes sont créées : la vérification d'unicité de request_change ne voit encore aucun compte propriétaire. Alice confirme la première : users.email d'Alice devient shared@example.com.

Bob clique ensuite. confirm_change re-teste l'unicité (étape 5) et échoue :

This email address is no longer available. Request the change again with a different address.

Le message est volontairement neutre : il ne nomme pas le compte qui détient désormais l'adresse. La ligne de Bob n'est pas marquée used_at, donc si Alice libère l'adresse avant expiration, le lien de Bob fonctionne encore. La contrainte UNIQUE sur users.email ferme la fenêtre de course résiduelle entre le SELECT et l'UPDATE : l'erreur SeaORM correspondante est traduite en ce même Validation, jamais en 500.

Testing plan

Tests unitaires (sans DB)

  • hash_token : SHA-256 hex, 64 caractères, déterministe, différent pour deux entrées (patron des tests existants de email_validation_service).
  • Limiteur : new_with_limiter isole du OnceLock de processus ; new partage le limiteur de processus ; le budget email_change:{user_id} est distinct de email_verify:{email} et pwd_reset:{email}. Reprendre les trois tests de email_validation_service.rs L433-495.
  • Model::is_expired / is_used / is_pending : table de vérité complète (4 combinaisons).
  • Rendu des quatre templates d'e-mail : render_html() Ok, render_text() non vide, pour les 6 locales, et l'adresse candidate apparaît masquée dans email_change_notice comme dans email_change_by_admin.
  • Rendu des pages interstitielles (fonction de construction HTML pure, testable sans DB ni serveur) : la sortie contient <form avec method="post", un <button, aucune balise <script, aucune URL absolue vers une ressource externe, et le token n'apparaît pas en clair dans le corps visible ; rendu Ok pour les 6 locales et sans I18nService (chaînes de repli).

Tests d'intégration (DB de test, patron user_service::tests L690-695)

Happy path :

  1. request_change avec le bon mot de passe → une ligne pending_email_changes (used_at IS NULL), users.email inchangé, email_verified toujours true, deux lignes email_queue ("email_change_validation" vers la nouvelle adresse, "email_change_notice" vers l'ancienne).
  2. confirm_change(token) → users.email = nouvelle adresse, email_verified = true, email_verified_at non nul, used_at renseigné, refresh tokens du user tous revoked = true, une ligne audit_logs action = "email_changed".

Cas d'erreur (≥ 3 exigés, on en couvre 8) :

  1. Mauvais mot de passe → AppError::InvalidCredentials, aucune ligne créée, aucun mail.
  2. Format invalide ("not-an-email") → Validation("Invalid email format"), aucune ligne, aucun mail.
  3. Adresse déjà détenue par un autre compte → Validation("Email '…' already exists").
  4. Adresse identique à l'adresse courante → Validation, aucune ligne.
  5. 4ᵉ appel dans l'heure → AppError::TooManyRequests avec retry_after_secs, aucune ligne.
  6. confirm_change avec un token inconnu / expiré (expires_at forcé dans le passé) / déjà consommé → trois Validation distinctes ; users.email inchangé dans les trois cas.
  7. Course : demande de B sur l'adresse que A vient de prendre → confirm_change(B) échoue, users.email de B inchangé, la ligne de B n'est pas used_at.
  8. Nouvelle demande alors qu'une demande est pendante → l'ancienne passe used_at, son token ne confirme plus.

Signalement :

  1. report_suspicious(report_token) → reported_at renseigné, une ligne audit_logs "email_change_reported" (level = "WARN"), un mail par administrateur, on_audit_event reçu (patron CapturingHooks de auth_logging.rs L79-96).
  2. Après signalement, confirm_change réussit quand même — c'est le test qui verrouille la décision « alerter, pas refuser ».
  3. report_suspicious appelé deux fois → idempotent, un seul lot de mails admin.
  4. Token de signalement inconnu → Validation, aucun mail.

Migration :

  1. up puis down puis up sur une base contenant des users : pas de perte de données, la FK cascade supprime bien les lignes pendantes quand l'utilisateur est supprimé (UserService::delete_user, user_service.rs L566-601).

Hooks :

  1. confirm_change avec hooks = Some(..) → on_email_verified reçu (décision n° 5 : dispatch côté service, donc le test appelle le service directement, sans passer par un handler) et on_audit_event("user.email_changed") reçu ; avec hooks = None → aucun panic (patron log_blocked_login_works_without_hooks, auth_logging.rs L128-140). Le CapturingHooks doit enregistrer l'ordre : on_email_verified et l'événement d'audit, pas l'un à la place de l'autre.
  2. PasswordChangeService::invalidate_on_email_change est bien appelé par confirm_change (décision n° 4) : créer une demande de changement de mot de passe pendante pour l'utilisateur, confirmer le changement d'adresse, vérifier que la ligne password_change_requests non confirmée a disparu. Test symétrique pour admin_force_change. Variante d'échec : si l'invalidation échoue, le changement d'adresse reste appliqué (l'erreur est loguée, pas propagée).

Routes — page interstitielle (décision n° 3) :

  1. GET /api/v1/auth/email-change/confirm/{token} n'a aucun effet de bord : après l'appel, pending_email_changes.used_at est toujours NULL, users.email est inchangé, email_verified inchangé, aucun refresh token révoqué, aucune ligne audit_logs ajoutée. La réponse est un 200 HTML contenant <form et method="post". C'est le test qui verrouille l'immunité au préchargement des passerelles anti-phishing.
  2. POST /api/v1/auth/email-change/confirm/{token} exécute : mêmes assertions que le happy path n° 2.
  3. GET .../report/{token} n'a aucun effet de bord : reported_at reste NULL, aucun mail administrateur en file. Puis POST .../report/{token} → reported_at renseigné et les mails partent.
  4. GET sur un token inconnu / expiré / déjà consommé → page 200 HTML affichant le message d'erreur correspondant, sans formulaire de soumission : on n'invite pas à poster un token inutilisable. Le 200 (et non un 4xx) est retenu pour les deux liens, par cohérence avec la page verify-email existante, qui sert déjà une page d'explication plutôt qu'un code d'erreur à un humain arrivant depuis sa boîte mail.
  5. La page rendue ne dépend d'aucun template : le test appelle directement la fonction de construction HTML, qui est une fonction pure (&str, …) -> String sans I/O, sans Template dérivé et sans accès disque. Un test de non-régression garde cette propriété : le module des pages ne doit importer ni askama, ni quoi que ce soit de rustwarden-ui.

Recours administrateur (décision n° 1) :

  1. Succès : admin_force_change(actor=admin, user=bob, "new@example.com") → users.email de Bob = new@example.com, email_verified = true, email_verified_at non nul, refresh tokens de Bob tous revoked = true, refresh tokens de l'admin intacts, une ligne audit_logs action = "email_changed_by_admin" / level = "WARN" avec user_id = admin.id et target_user_id = bob.id, deux mails "email_change_by_admin" en file (ancienne et nouvelle adresse).
  2. Refus si l'acteur n'est pas administrateur : actor sans rôle admin → AppError::Forbidden, users.email inchangé, aucune ligne audit_logs, aucun mail. Test complémentaire : acteur inexistant → AppError::NotFound.
  3. Trace d'audit et hook : avec CapturingHooks, on_audit_event reçoit kind = "user.email_changed_by_admin", actor_id = Some(admin.id), target = Some(("user", bob.id)) ; on_email_verified n'est PAS émis (il n'y a pas eu de preuve de possession).
  4. Demande pendante consommée : Bob a une demande en cours, l'admin force une autre adresse → la demande de Bob est marquée used_at et son token de confirmation ne fonctionne plus (sinon il réécrirait l'adresse posée par l'opérateur).
  5. Adresse déjà prise → Validation, rien n'est écrit. Écho de l'adresse courante → Ok, no-op, aucun mail (le formulaire admin renvoie tous les champs).

Rétention (décision n° 6) :

  1. cleanup_expired supprime les lignes dont expires_at < now() - 30 jours — y compris les lignes consommées et signalées — et laisse intactes celles expirées depuis moins de 30 jours ainsi que les demandes encore pendantes. Le compteur retourné correspond au nombre de lignes supprimées.
  2. Purge opportuniste : une ligne de l'utilisateur, expirée depuis plus de 30 jours, disparaît au prochain request_change du même utilisateur ; une ligne d'un autre utilisateur, elle, n'est pas touchée.
  3. PendingEmailChangeCleanupProcessor::new(db).start() ne panique pas et purge au premier tick (patron des tests de processeur existants ; à défaut, test de la boucle réduit à une itération). Le test d'app.rs reste inchangé : le processeur n'est pas démarré par start_background_tasks, et un test de non-régression le vérifie explicitement pour qu'un démarrage automatique ne soit pas introduit par mégarde.

Tests de non-régression

  • Le code consommateur pré-RFC compile sans warning : la signature d'update_profile est identique. Un test le vérifie en appelant les quatre formes (Some, Some), (Some, None), (None, Some), (None, None).
  • update_profile(db, id, None, Some(<adresse courante>)) → Ok, aucun changement, aucun mail (le formulaire profil de rustwarden-ui renvoie toujours le champ).
  • update_profile(db, id, None, Some(<autre adresse>)) → AppError::Validation dont le message contient EmailChangeService (contrat de découvrabilité de l'erreur).
  • Rename username seul : comportement RFC 0036 intact (Username::parse sur vrai rename, grandfathering préservé).
  • EmailValidationService::verify_email : inchangé, toujours idempotent.
  • update_profile(db, id, None, Some(<autre adresse>)) appelé depuis un contexte administrateur échoue exactement de la même façon : il n'y a pas de branche dérogatoire cachée dans update_profile, le seul chemin opérateur est admin_force_change.
  • auth_api_routes() compose toujours avec un handler applicatif (test existant auth_api_routes_compose_with_scaffolded_protected_handler, auth/routes.rs L99-106) ; deux tests à ajouter : POST /api/v1/auth/email-change est bien soumis au budget IP (11ᵉ requête → 429), et les quatre routes de lien (GET + POST sur confirm et report) ne le sont pas, sur le patron de auth_limiter_separates_two_connect_ips (L147-194) avec des IP TEST-NET non réutilisées (le limiteur est un OnceLock de processus).
  • Aucune des quatre routes de lien n'est montée derrière la couche CSRF : un POST sans en-tête ni cookie CSRF aboutit (canal machine). Un test le vérifie pour POST .../confirm/{token}, faute de quoi la page interstitielle serait inutilisable depuis une boîte mail.

Doctests

Au moins un exemple copiable par item public : EmailChangeService::new, with_i18n, request_change, confirm_change, report_suspicious, admin_force_change, cleanup_expired, PendingEmailChangeCleanupProcessor::new / start. Tous ceux qui exigent une DB → ```rust,ignore avec un paragraphe rustdoc anglais décrivant le contrat, patron RefreshTokenService::revoke_all_tokens_for_user (```no_run, refresh_token_service.rs L342-353) et des services I/O des RFC 0036/0038. Le doctest de admin_force_change doit montrer l'actor_id et rappeler la condition de rôle ; celui du processeur doit montrer qu'il faut appeler .start() soi-même.

Benchs

Non nécessaires. request_change ajoute un bcrypt (verify_credentials) sur un chemin rare et déjà limité à 3/h par utilisateur ; confirm_change ajoute un SELECT indexé, deux UPDATE, une révocation de refresh tokens et un DELETE de demandes de changement de mot de passe — tous indexés, tous sur un chemin exécuté au plus quelques fois dans la vie d'un compte. Les GET interstitiels ajoutent un SELECT indexé. admin_force_change est du même ordre, sur un chemin encore plus rare. La purge s'exécute hors requête utilisateur. Aucun hot path (login, refresh, extraction JWT) n'est touché.

Baseline avant implémentation

cargo test -p rustwarden-core user_service --lib et cargo test -p rustwarden-core email_validation_service --lib avant/après. Les exemples EmailChangeService:: ne sont pas exécutables tant que le service n'est pas livré.

Couverture réelle (implémentation du 2026-09-19)

cargo test -p rustwarden-core --lib : 357 tests verts, dont 62 rattachés à cette RFC. Répartition :

EmplacementTestsCouvre
services::email_change_service::tests36items 1–14, 16–17, 23–29, les bullets hash_token et « budget de limiteur », plus un test hors plan de parité des clés de traduction entre les six fichiers de locale
auth::email_change_pages::tests6item 22 et le bullet « rendu des pages interstitielles » : formulaire POST, pas de <script, pas de ressource externe, token absent du corps, page d'erreur sans formulaire, non-régression « ni Askama ni rustwarden-ui »
auth::email_change_handlers::tests6items 18–21 et la non-régression CSRF (POST sans en-tête ni cookie → succès)
templates::email_templates::email_change_template_tests4bullet « rendu des quatre templates » : HTML + texte dans les six locales, échappement des champs pré-traduits, et le fait que l'avis à l'ancienne adresse ne porte jamais le lien de confirmation
models::pending_email_change::tests2bullet « table de vérité » is_pending / is_used / is_expired
auth::routes::tests2bullet « budget IP » : limite sur POST /email-change, absence de limite sur les quatre routes de lien
tasks::pending_email_change_cleanup::tests2item 30 (purge au premier tick ; non-régression : app.rs ne démarre pas le processeur)
services::user_service::tests4non-régressions update_profile : refus, écho no-op, adresse prise, contexte admin

Écarts assumés par rapport au plan ci-dessus :

  • Item 15 (up / down / up de la migration) : non automatisé. Le dépôt ne teste aucune migration par du code ; la séquence a été jouée à la main sur la base de dev au moment du correctif de chk_email_queue_email_type (rollback de la migration 11, ré-application, vérification de pg_get_constraintdef). La partie qui compte vraiment — la cascade ON DELETE — est couverte, elle, par deleting_a_user_cascades_to_their_pending_requests.
  • Item 17, variante d'échec (« si l'invalidation des demandes de mot de passe échoue, le changement d'adresse reste appliqué ») : non couverte, faute de point d'injection dans PasswordChangeService. Le contrat est tenu par construction — l'appel est dans un if let Err(e) = … { tracing::warn!(…) } après l'écriture de l'adresse — mais rien ne l'empêche de régresser.
  • Item 14, « aucun mail » : couvert indirectement. Le fan-out administrateur est derrière l'écriture de reported_at ; compter les mails admin directement ferait courir les tests les uns contre les autres sur la base partagée.
  • Tests administrateur : ils s'auto-neutralisent (return silencieux) quand le seed RBAC est absent de la base de test, faute de rôle admin à attribuer. Sur une base seedée ils s'exécutent réellement ; sur une base nue ils ne protègent rien.
  • Doctests : tous en ```rust,ignore comme prévu, donc ni compilés ni exécutés. Ils documentent, ils ne vérifient pas.
  • Tests de rétention : sérialisés entre eux par un RETENTION_GUARD (tokio::sync::Mutex de portée module, partagé avec le test du processeur). cleanup_expired supprime les lignes périmées de tous les utilisateurs : sans ce verrou, deux tests de rétention concurrents se détruisent mutuellement leurs fixtures.

Défaut de production trouvé par l'exécution des tests : la contrainte chk_email_queue_email_type (§ 1) rejetait les quatre types d'e-mail de la RFC — le flux entier échouait au premier INSERT. Corrigé dans la migration m20260919_000011.

Correction de cohérence : confirm_change émettait son AuditEvent avec target: None, là où report_suspicious et admin_force_change nomment tous deux Some(("user", user_id)). Un consommateur qui route ses événements sur target aurait vu le chemin administrateur et pas le chemin libre-service. Aligné sur les deux autres.

Migration guide

Section à copier telle quelle dans le CHANGELOG.md au moment du merge. Elle remplace la section « RFC 0038 » de l'entrée 1.1.0 — la v1 n'ayant jamais été publiée, aucune note de transition v1 → v2 n'est nécessaire.

### API changes

**Before (v1.0.x)** :
`UserService::update_profile` ecrivait `users.email` immediatement. Aucun
mecanisme de confirmation : l'adresse saisie devenait l'adresse du compte.

**After (v1.1.0 — RFC 0041)** :
Le changement d'adresse est en **deux temps**. Tant que le lien envoye a la
nouvelle boite n'est pas suivi, **rien ne change en base** : l'adresse
courante reste celle du compte et reste verifiee.

\`\`\`rust
// Nouveau chemin
use rustwarden_core::services::email_change_service::EmailChangeService;

EmailChangeService::new(email_config)
    .request_change(&db, user_id, "new@example.com", current_password)
    .await?;
// -> users.email inchange ; deux mails en file d'attente.
// -> l'adresse n'est ecrite qu'apres le POST de la page de confirmation
//    ouverte par GET /api/v1/auth/email-change/confirm/{token}

// Ancien chemin : erreur explicite, plus d'ecriture silencieuse
UserService::update_profile(&db, user_id, None, Some("new@example.com".into())).await
// -> Err(AppError::Validation("Email changes are not handled by update_profile; ..."))

// Recours operateur (remplace PUT /admin/users/{id} pour l'adresse)
EmailChangeService::new(email_config)
    .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None)
    .await?;
// -> ecriture immediate, sessions de la cible revoquees, audit_logs WARN.
\`\`\`

### Nouvelles routes (canal machine, `merge_machine_routes`)

Les liens envoyes par e-mail ouvrent une **page interstitielle** : le `GET`
affiche un formulaire, c'est le `POST` du bouton qui agit. Aucun `GET` n'a
d'effet de bord, donc aucune passerelle anti-phishing qui precharge les liens
ne peut confirmer un changement d'adresse a la place de l'utilisateur.

| Methode | Chemin | Auth | Effet |
|---|---|---|---|
| POST | `/api/v1/auth/email-change` | Bearer + **mot de passe courant** dans le corps | ouvre la demande |
| GET | `/api/v1/auth/email-change/confirm/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien |
| POST | `/api/v1/auth/email-change/confirm/{token}` | public (bouton de la page) | applique le changement |
| GET | `/api/v1/auth/email-change/report/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien |
| POST | `/api/v1/auth/email-change/report/{token}` | public (bouton de la page) | enregistre le signalement |

Les pages sont du HTML minimal autoportant produit par le core : ni Askama, ni
`rustwarden-ui`, ni JavaScript, ni ressource externe. Un consommateur qui veut
sa propre presentation monte sa propre route `GET` et poste vers la route
`POST` du core.

### Migration steps

1. Bump `rustwarden-core` a la version qui livre la RFC 0041.
2. **Jouer la migration SeaORM** `m20260919_000011_pending_email_changes`
   (`cargo run --bin migrate`, ou `auto_migrate(true)` au boot). La table est
   nouvelle : aucune donnee existante n'est touchee, `down` la supprime.
3. Chercher les appels a `update_profile` qui passent une **nouvelle** adresse :
   \`\`\`bash
   grep -rn "update_profile" src/
   \`\`\`
   Les rediriger vers `EmailChangeService::request_change`. Un echo de
   l'adresse **courante** reste un no-op : les formulaires qui resoumettent
   tous les champs continuent de fonctionner sans modification.
4. **Chemin administrateur : `PUT /admin/users/{id}` ne change plus l'adresse.**
   Tout consommateur qui appelait cette route (ou directement
   `update_profile` depuis un handler admin) pour corriger l'e-mail d'un
   compte doit basculer sur :
   \`\`\`rust
   EmailChangeService::new(email_config)
       .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None)
       .await?;
   \`\`\`
   La methode exige que `admin_id` porte le role `admin` (sinon
   `AppError::Forbidden`), ecrit l'adresse sans confirmation, revoque les
   sessions du compte cible, notifie les deux boites et trace l'operation
   dans `audit_logs` (`email_changed_by_admin`, niveau WARN) ainsi que via
   `RustwardenHooks::on_audit_event`. C'est la **meme capacite** qu'avant,
   mais explicite et auditee au lieu d'etre un effet de bord de la mise a
   jour de profil. Le reste de `PUT /admin/users/{id}` (username, roles,
   statut) est inchange.
5. Si vous voulez purger les demandes expirees, demarrez vous-meme le
   processeur — le framework ne le fait pas :
   \`\`\`rust
   PendingEmailChangeCleanupProcessor::new(db.clone()).start();
   \`\`\`
   Retention : 30 jours apres expiration, constante de code, aucune variable
   d'environnement a definir. Sans ce demarrage, une purge opportuniste
   limitee a l'utilisateur courant s'execute a chaque nouvelle demande.
6. Supprimer tout code applicatif qui, apres un changement d'adresse,
   remettait `email_verified` a la main, enqueuait un mail de validation ou
   une alerte `account_modified` : le service s'en charge.
7. Supprimer l'appel applicatif a
   `PasswordChangeService::invalidate_on_email_change` (il etait typiquement
   fait au moment de l'edition du profil) : le core l'emet desormais lui-meme
   dans `confirm_change` et `admin_force_change`, c'est-a-dire aux deux seuls
   instants ou l'adresse change reellement.
8. Exposer les deux liens dans vos gabarits de mail si vous surchargez les
   templates du framework ; sinon les templates du core suffisent (6 locales).
   Attention au libelle : le lien **ouvre une page de confirmation**, il ne
   confirme pas directement.
9. Verifier SMTP / worker `email_queue` **avant** le bump : sans file d'attente
   fonctionnelle, `request_change` echoue (volontairement) au lieu d'ecrire une
   adresse non prouvee.
10. Cote UI : rien d'obligatoire. Le champ e-mail du formulaire de profil ne
    produit plus d'erreur tant qu'il renvoie l'adresse courante. Les ecrans
    dedies (demande en cours, annulation, double saisie) sont un chantier UI
    separe. Si vous voulez remplacer les pages interstitielles du core par les
    votres, montez vos routes `GET` et postez vers les routes `POST` du core.
11. Prevenir le support : un utilisateur qui a perdu **a la fois** son mot de
    passe et l'acces a son adresse courante ne peut plus se debloquer seul ;
    c'est un recours administrateur (`admin_force_change`) apres verification
    d'identite hors bande. Une faute de frappe **a l'inscription** releve du
    meme recours : ce flux n'est pas traite par cette RFC.

### Ce qui disparait par rapport a la v1 annoncee (RFC 0038)

- Plus de bascule `email_verified = false` a l'edition du profil.
- Plus de lock-out sur faute de frappe : la demande expire, le compte n'a
  jamais bouge.
- Plus de couplage « changement d'adresse => prochain login refuse »
  (interaction avec la RFC 0040 supprimee).
- Plus de changement d'adresse via `PUT /admin/users/{id}` : la capacite
  operateur existe toujours, mais sous un nom et une trace d'audit qui lui
  sont propres (`admin_force_change`).

Fichiers de documentation à aligner dans le même patch (hors livrables de cette RFC, mais bloquants pour le merge) : CHANGELOG.md (section RFC 0038 de l'entrée 1.1.0), docs/MIGRATION_GUIDE.md (§ RFC 0038, dont l'encadré « faute de frappe » devient caduc, plus le recours admin_force_change et la non-activation du processeur de purge), docs/SERVICES_REFERENCE.md (L142 et § L177, à compléter avec EmailChangeService, ses méthodes publiques et le processeur de purge), docs/developer-guide/07-security-model.md (L24, L392, L407), et tools/rustwarden-cli/src/templates.rs (L358-364, ligne commentée du PendingEmailChangeCleanupProcessor).

Open questions

Toutes tranchées le 2026-09-19 par le mainteneur. La RFC passe en Accepted ; les conséquences sont intégrées au corps du document, section par section.

  • Recours administrateur. Un admin doit-il pouvoir forcer une adresse sans confirmation (méthode EmailChangeService::admin_force_change, tracée dans audit_logs), ou le recours passe-t-il par une procédure documentée (reset password + demande faite par l'utilisateur) ? La v1 permettait PUT /admin/users/{id} → l'API perd une capacité opérationnelle si on ne tranche pas. Tranché (2026-09-19) : Méthode dédiée EmailChangeService::admin_force_change : écrit l'adresse sans confirmation, exige un acteur administrateur, trace dans audit_logs + on_audit_event, révoque les sessions du compte cible. La capacité opérationnelle de la v1 est préservée mais devient explicite et auditée, au lieu d'un effet de bord d'update_profile. → § API proposée / 3 (signature + rustdoc), § « admin_force_change — séquence et garde-fous », § Testing plan (cas 23-27), § Migration guide (étape 4).
  • Oracle d'existence d'adresse. request_change renvoie Email '…' already exists (message hérité de update_profile). Faut-il lui préférer un 202 générique, à la manière de resend_verification / request_reset qui ne révèlent rien ? Tranché (2026-09-19) : Garder l'erreur explicite Email '…' already exists. L'appel exige le mot de passe courant et est limité à 3/h par utilisateur : l'énumération est impraticable, et un 202 silencieux transformerait une adresse déjà prise en échec invisible pour l'utilisateur légitime. → aucun changement : § request_change étape 4 et § Testing plan cas 5 décrivaient déjà ce comportement.
  • Confirmation par GET et pré-chargement des liens. On accepte le GET par cohérence avec verify-email/{token}, au prix d'une confirmation possiblement déclenchée par une passerelle anti-phishing. Faut-il une page interstitielle avec bouton POST pour les deux liens, ou seulement pour la confirmation ? Tranché (2026-09-19) : Page interstitielle sur les DEUX liens (confirmation ET signalement) : le lien ouvre une page servant un formulaire POST avec bouton. Aucun préchargement de passerelle anti-phishing ne peut déclencher l'action. Contrainte headless : la page doit être servie par le core sans dépendre de rustwarden-ui ni d'Askama — HTML minimal autoportant construit dans le handler, surchargeable par un consommateur qui monte sa propre route. → § Routes entièrement réécrite (5 routes, tableau des effets, contrainte headless), § Testing plan (cas 18-22), § Migration guide (tableau des routes).
  • PasswordChangeService::invalidate_on_email_change. Aujourd'hui appelé par l'UI (settings/profile.rs L55-68) au moment de l'édition du profil. Doit-il migrer dans confirm_change (l'adresse change vraiment à ce moment-là) ou disparaître ? Tranché (2026-09-19) : Migre dans EmailChangeService::confirm_change (côté core). Avec la v2, l'UI n'est plus dans la boucle au moment où l'adresse change réellement : laisser l'appel dans le handler UI le perdrait silencieusement. → § confirm_change étape 9, § admin_force_change étape 7, § 6 (adaptation UI), § Testing plan (cas 17), § Migration guide (étape 7).
  • on_email_verified sur confirm_change. Le hook est aujourd'hui émis uniquement par le handler verify_email (auth/handlers.rs L1222) après consommation d'un token EmailValidationService. L'émettre depuis EmailChangeService élargit son contrat — cohérent, mais à confirmer par le mainteneur. Dispatch côté service ou côté handler ? Tranché (2026-09-19) : Émis depuis confirm_change, dispatch côté service (patron AuthLoggingService). L'adresse est prouvée par le clic : le contrat du hook (« l'adresse du compte est désormais prouvée ») reste cohérent. → § confirm_change étape 11, § Testing plan (cas 16). Corollaire explicité : admin_force_change n'émet pas ce hook, faute de preuve de possession.
  • Rétention. Purge opportuniste + cleanup_expired public, ou PendingEmailChangeCleanupProcessor en bonne et due forme — sachant que PersonalTokenCleanupProcessor et PasswordChangeCleanupProcessor ne sont pas démarrés par app.rs aujourd'hui ? Et 30 jours en constante, ou une variable d'environnement ? Tranché (2026-09-19) : PendingEmailChangeCleanupProcessor en bonne et due forme, calqué sur PersonalTokenCleanupProcessor, plus une méthode publique cleanup_expired. Rétention 30 jours en constante, pas de nouvelle variable d'environnement. Documenter — comme pour les processeurs existants — que app.rs ne le démarre pas automatiquement et que le consommateur doit appeler .start(). → § 8 « Rétention et tâche de fond » réécrite, § Testing plan (cas 28-30), § Migration guide (étape 5).
  • Faute de frappe à l'inscription. Cette RFC ferme la faute de frappe au changement d'adresse, pas à l'inscription (register écrit l'adresse saisie, non vérifiée). Un utilisateur qui se trompe au signup reste bloqué. RFC séparée, ou double saisie imposée côté register ? Tranché (2026-09-19) : Hors périmètre : register écrit l'adresse saisie sans double vérification, un utilisateur qui se trompe au signup reste bloqué. Flux distinct (compte non encore établi, aucune identité à protéger) qui mérite son propre arbitrage (double saisie vs renvoi de lien). À déclarer en Non-goal + follow-up. → § Non-goals (bullet register développé + follow-up), § Migration guide (étape 11).
  • Locale des e-mails. Lecture de user_preferences.language pour les nouveaux templates uniquement, ou extension au même mouvement des templates existants (email_validation, password_reset, welcome) qui codent Some("fr") en dur ? Tranché (2026-09-19) : Les trois nouveaux templates lisent la préférence utilisateur ; la dette des templates existants qui codent Some("fr") en dur est traitée séparément. → § 7 inchangée sur le principe. Note de périmètre : les templates sont passés de trois à quatre (email_change_by_admin, conséquence de la décision n° 1) ; la règle « les nouveaux templates lisent la préférence, les anciens ne sont pas retouchés » s'applique aux quatre.
  • Tracking. Ouvrir une issue GitHub avant le passage en Under Review ? Tranché (2026-09-19) : Suivi interne via docs/plans/, pas d'issue externe. → § Tracking ci-dessous.

Tracking

Tranché le 2026-09-19 : suivi interne, pas d'issue GitHub. Le suivi de l'implémentation vit dans docs/plans/, versionné avec le code, au même titre que les autres chantiers du dépôt — la RFC elle-même tenant lieu de référence stable. Pas d'URL externe à maintenir, pas de risque de divergence entre une issue et le document.

À référencer depuis le plan d'implémentation : la RFC 0038 que celle-ci remplace, la § « v2 (follow-up) » de cette dernière, et l'encadré « faute de frappe = lock-out » de docs/MIGRATION_GUIDE.md (qui devient caduc au merge).

Follow-ups identifiés, à suivre dans docs/plans/ eux aussi, chacun hors périmètre de cette RFC :

  1. Faute de frappe à l'inscription (décision n° 7) — double saisie au formulaire d'inscription vs renvoi de lien vs purge des comptes jamais validés.
  2. Refonte UI du parcours de changement d'adresse — écran « demande en cours », annulation, double saisie, suivi côté admin.
  3. Dette i18n des templates existants (décision n° 8) — email_validation, password_reset, welcome codent Some("fr") en dur.
  4. verify-email/{token} agit toujours sur GET — la page interstitielle n'a été imposée qu'au nouveau flux ; aligner l'ancien est un chantier distinct, avec sa propre compatibilité à gérer (les liens déjà envoyés doivent continuer de fonctionner).

Score d'évaluation

Notes arrêtées à l'acceptation du 2026-09-19, après intégration des neuf arbitrages. Seuil Accepted : ≥ 25/35.

CritèreNote (1-5)Commentaire
Généralité5Core uniquement, aucune dépendance UI — la contrainte headless posée sur les pages interstitielles le garantit désormais explicitement. Un point d'entrée unique pour Gitrust, rustwarden-ui et toute app headless, utilisateur comme opérateur (admin_force_change) ; remplace N implémentations divergentes.
Stabilité API3Ajout pur côté items publics (tout consommateur compile), mais breaking comportemental assumé sur update_profile + une nouvelle table. Atténué : la v1 n'a jamais été publiée, personne ne migre depuis elle. Note inchangée malgré admin_force_change : c'est un ajout pur, et le consommateur qui changeait une adresse via PUT /admin/users/{id} était de toute façon déjà impacté par le breaking comportemental.
Performance4Aucun hot path touché. Un bcrypt sur request_change, limité à 3/h par utilisateur et couvert par le budget IP ; un SELECT indexé et deux UPDATE sur confirm_change. Les GET interstitiels ajoutent un SELECT indexé sur un chemin rare et non authentifié ; la purge s'exécute sur une table de travail de petite taille.
Sécurité5Supprime la classe des lock-out par changement d'adresse, exige une re-authentification mot de passe, révoque les sessions, alerte l'ancienne boîte avec un moyen d'action, trace dans audit_logs et on_audit_event. Les arbitrages renforcent encore ce critère : aucun GET n'écrit (immunité au préchargement des passerelles), le recours admin est authentifié par rôle et audité au lieu d'être un effet de bord, et l'invalidation des demandes de changement de mot de passe ne peut plus être perdue par un consommateur sans UI.
Complexité d'implémentation1Abaissée de 2 à 1 le 2026-09-19 : les arbitrages élargissent nettement le périmètre. Au chantier initial (migration + modèle + service + 3 templates × 6 locales + retrait du code v1 — update_profile L277-301 et notify_email_change L327-419 — + adaptation UI minimale) s'ajoutent : admin_force_change avec sa vérification de rôle et son audit propre, 5 routes au lieu de 3 (page interstitielle GET → POST sur les deux liens), un rendu HTML autoportant sans templating ni JS avec ses propres libellés i18n, un 4ᵉ template d'e-mail, et un PendingEmailChangeCleanupProcessor. C'est le chantier le plus lourd du train, de loin.
Charge de maintenance3Une table de plus avec politique de rétention et PII en clair ; ~24 blocs de traduction (4 e-mails + pages interstitielles × 6 locales) ; deux flux de tokens à garder cohérents avec EmailValidationService ; un processeur de purge de plus dans le catalogue — mais non démarré par défaut, donc sans coût d'exploitation imposé. Compensé par la suppression du code v1 et de ses cas limites, et par le fait qu'aucune variable d'environnement n'est ajoutée.
Documentation5Relevée de 4 à 5 le 2026-09-19 : la déduction portait sur le recours admin « resté en Open question », qui est désormais spécifié, testé et documenté de bout en bout (rustdoc, séquence, migration guide). RFC + CHANGELOG + MIGRATION_GUIDE + SERVICES_REFERENCE + security-model + rustdoc anglais et doctest sur chaque item public, y compris le processeur de purge et sa non-activation par défaut. L'UI complète reste à faire, mais c'est un Non-goal explicite assorti d'un follow-up nommé — une décision documentée, pas un trou de documentation.
Total26/35Inchangé : −1 sur la complexité d'implémentation (périmètre élargi par les décisions 1, 3 et 6), +1 sur la documentation (les neuf Open questions sont tranchées et leurs conséquences écrites). ≥ 25 → Accepted, implémentable.

Lecture de l'ajustement. Le total identique n'est pas une commodité : les arbitrages ont déplacé du coût depuis l'incertitude (« on verra à l'implémentation ») vers le travail explicite (« il y a 5 routes, une page HTML à écrire, une méthode admin et un processeur »). Le périmètre a grossi — d'où le −1 en complexité — mais il n'y a plus de zone non spécifiée — d'où le +1 en documentation. Le seuil de 25 reste franchi avec une marge de 1 point ; l'implémentation devra rester dans le périmètre décrit, toute extension supplémentaire justifiant une nouvelle RFC plutôt qu'un élargissement silencieux de celle-ci.