RFC 0010 — Fediverse: signaux lifecycle + retry/backoff outbound
- Auteur : EBO (rustwarden)
- Date : 2026-05-09
- Statut : Accepted (auto-revue mainteneur unique 2026-05-09, score 26/35)
- Apps consommatrices concernées : toute app utilisant
rustwarden-corequi doit appliquer les signaux ActivityPubUpdate{Actor}/Delete{Actor}/Revoke{Membership}reçus, ou utiliser le retry/backoff complet du worker outbound. - Plan associé :
docs/plans/2026-05-09-fediverse-identity-federation.md, Phase 4. - Dépendances : RFC 0003 (
remote-identity-store), RFC 0007 (JWT claims), RFC 0008 (identity-abstraction), RFC 0009 (invitations), RFC 0011 (file outbound).
Summary
Introduire le service FediverseSignalService qui applique trois signaux entrants (apply_update_actor, apply_delete_actor, apply_revoke_membership), étendre OutboundStatus et FediverseOutboundService avec un ladder de retry / backoff (Failed → Pending après next_retry_at, Failed → Dead après max_attempts), et exposer un trait pluggable SignatureVerifier (impl AlwaysAcceptVerifier pour les tests, HttpSigVerifier réelle reportée à une RFC dédiée crypto). Toutes les routes HTTP qui reçoivent ces signaux (/inbox) et les API JSON admin/IdP (/api/v1/fediverse/connected-apps, /api/v1/admin/fediverse/sessions-remote) sont explicitement reportées à une follow-up RFC routes-only ; cette RFC livre la couche service complète + tests.
Motivation
Sans cette RFC, un utilisateur fédéré dont le compte distant est supprimé garde indéfiniment une session active sur b.tld ; un membre d'équipe distant retiré côté IdP n'est jamais retiré du team_members ; le worker outbound de RFC 0011 abandonne après le premier échec et perd l'activité.
Sans cette RFC, le master plan §10 Phase 4 « Delete{Actor} → toutes les sessions liées invalidées en moins de 30 s » est non-tenable et la Phase 5 (Production) ne peut pas commencer.
Non-goals
- Pas de routes HTTP
/inboxcôté serveur — un follow-up RFC routes-only ajoutera l'endpoint AP unique qui dispatche vers le service selon letypede l'activité. - Pas de UI HTML des "apps connectées" / sessions remote admin —
rustwarden-uiPhase 4 finale. - Pas d'implémentation cryptographique HTTP-Sig dans cette RFC. Le trait
SignatureVerifierest livré, et l'implAlwaysAcceptVerifier(test/dev) ; laHttpSigVerifierréelle (RFC 9421) sera une RFC séparée focalisée crypto, qui s'enficherait dans le même trait sans toucher aux handlers de signaux. - Pas de signature HTTP sortante (
HttpSignedDelivery) — même argument : trait pluggable + implNoopDelivery(RFC 0011),HttpSignedDeliveryréelle dans la même RFC crypto séparée. - Pas de monitoring/Prometheus — différé à Phase 5.
API proposée
Extension OutboundStatus + retry ladder
Le statut Failed n'est plus terminal. La méthode drain_one calcule next_retry_at lors d'un échec et bascule à Dead après max_attempts.
impl OutboundStatus { /// Backoff per RFC 0010: 5 min → 30 min → 2 h → 6 h → 24 h → 48 h … /// Index = `attempts` (after increment), capped at the last entry. pub fn next_retry_offset(attempts: i32) -> Duration; }
drain_one désormais :
- SELECT pending row dont
next_retry_at IS NULL OR next_retry_at <= NOW(). - Tente la livraison via
OutboundDelivery::deliver. - Sur succès →
Delivered. - Sur échec :
- Si
attempts + 1 >= max_attempts→Dead. - Sinon →
Pending(sic — pour rester éligible) avecnext_retry_at = NOW() + next_retry_offset(attempts + 1). Le statut restependingtout en étant gated par le timer ; le scanWHERE status='pending' AND next_retry_at <= NOW()ignore les rows en attente.
- Si
Note : dans le slice livré par RFC 0011 le statut
Failedétait terminal sans retry. RFC 0010 le rend transitoire ; les rowsFailedcréées par l'ancien chemin sont laissées en l'état (RFC 0011 livré déjà comme Phase 2 minimal — pas de migration de données nécessaire pour conserver la cohérence sémantique).
Service FediverseSignalService
pub struct FediverseSignalService; impl FediverseSignalService { /// AP `Update{Actor}` — upsert actor metadata in `remote_identities`. /// Idempotent: a second call with the same payload is a no-op /// beyond a `last_fetched_at` bump. pub async fn apply_update_actor( db: &DatabaseConnection, actor: UpdatedActor, ) -> Result<remote_identity::Model, AppError>; /// AP `Delete{Actor}` — set `disabled_at = NOW()` and revoke any /// JWTs whose `remote_id` matches via `JwtBlacklistService`. /// Returns the number of rows affected. pub async fn apply_delete_actor( db: &DatabaseConnection, ap_id: &str, ) -> Result<DeleteOutcome, AppError>; /// AP `Revoke{Membership}` — drop the matching `team_members` row. /// Returns true if a row was removed. pub async fn apply_revoke_membership( db: &DatabaseConnection, team_id: Uuid, remote_identity_id: Uuid, ) -> Result<bool, AppError>; } #[derive(Debug, Clone)] pub struct UpdatedActor { pub ap_id: String, pub display_name: Option<String>, pub summary_html: Option<String>, pub icon_url: Option<String>, pub public_key_pem: Option<String>, pub raw_json: Option<serde_json::Value>, } #[derive(Debug, Clone, Copy, Default)] pub struct DeleteOutcome { pub rows_disabled: u64, pub jwts_blacklisted: u64, }
Trait SignatureVerifier
/// Signature verifier for incoming AP activities. Implementations /// receive the raw HTTP request (headers + body bytes + actor pubkey) /// and decide whether to accept the signal. #[async_trait] pub trait SignatureVerifier: Send + Sync { /// Verify that `signature_header` covers `body` and matches the /// public key the resolved AP `actor` is supposed to sign with. async fn verify( &self, actor_ap_id: &str, signature_header: &str, body: &[u8], public_key_pem: &str, ) -> Result<(), AppError>; } /// Test/dev implementation that accepts any signature. **Never use /// in production** — see RFC dedicated crypto (followup) for the real /// HTTP-Sig RFC 9421 implementation. pub struct AlwaysAcceptVerifier;
Analyse de compatibilité
- Non-breaking : nouveau service + trait. Aucune signature publique existante n'est modifiée.
-
La sémantique de
OutboundStatus::Failedchange (transitoire, plus terminal). Les rowsFailedlegacy restent inertes : aucune migration de données. - Pas de modification de schéma DB.
Alternatives considérées
Alternative A (recommandée) — Service séparé + trait verifier pluggable
Pros : isolation propre, testable sans crypto, RFC future pourra brancher l'impl HTTP-Sig sans toucher aux handlers.
Cons : 1 trait supplémentaire à maintenir.
Alternative B — Implémenter HTTP-Sig dans la même RFC
Cons : ~1500 lignes supplémentaires, blocage de la livraison Phase 4 par un point dur cryptographique.
Rejeté.
Alternative C — Pas de retry/backoff (statut Failed reste terminal)
Cons : aucune résilience face aux indisponibilités passagères ; le master plan exige SLA livraison < 5 min P99.
Rejeté.
Alternative D — Ne rien faire
Cons : Phase 4 incomplète, Phase 5 bloquée.
Rejeté.
Testing plan
12 tests :
Pure :
next_retry_offset_grows_then_caps— séquence5min, 30min, 2h, 6h, 24h, 48h, 48h….delete_outcome_default_is_zero.
DB-mocked (sea-orm/mock) :
3. apply_update_actor_upserts_when_existing — appel sur un ap_id existant met à jour.
4. apply_update_actor_inserts_when_missing — appel sur un ap_id nouveau insère la ligne.
5. apply_delete_actor_sets_disabled_at.
6. apply_delete_actor_returns_outcome.
7. apply_revoke_membership_returns_false_when_no_row.
8. apply_revoke_membership_returns_true_when_row_dropped.
9. drain_one_marks_failed_pending_with_next_retry_at_when_below_max_attempts.
10. drain_one_marks_dead_when_attempts_reach_max.
Trait :
11. always_accept_verifier_accepts_any_input.
12. signature_verifier_trait_object_works — un Box<dyn SignatureVerifier> reste utilisable.
Migration guide
### Added (rustwarden-core, feature `experimental-fediverse`) - `FediverseSignalService` (apply_update_actor / apply_delete_actor / apply_revoke_membership). - Types `UpdatedActor`, `DeleteOutcome`. - Trait `SignatureVerifier` + impl `AlwaysAcceptVerifier`. - Méthode `OutboundStatus::next_retry_offset(attempts)`. - Comportement de `FediverseOutboundService::drain_one` étendu : retry/backoff ladder + transition `Pending → Dead` quand `attempts >= max_attempts`. ### Migration steps 1. Aucun changement de schéma. Pas de `cargo run --bin migrate`. 2. Apps consommatrices : injecter `Box<dyn SignatureVerifier>` au moment d'écrire le handler `/inbox` (RFC follow-up). 3. Le retry est automatique côté worker dès le premier `drain_one` après l'upgrade.
Open questions
Toutes résolues à l'auto-revue :
-
Backoff ladder :
[5m, 30m, 2h, 6h, 24h, 48h]— conforme master plan §10 Phase 4. -
Failedreste-t-il un état distinct ? : non utilisé dans le ladder de RFC 0010 (on bascule directementpendingavecnext_retry_at), mais la variante reste pour permettre à un futur opérateur de marquer manuellement une row morte. -
apply_delete_actordoit-il blacklist les JWT actifs ? : oui, viaJwtBlacklistService. La méthode actuelle requiert un token concret ; le service est étendu (ou contourné via une nouvelle méthodeblacklist_for_remote_identity) — la livraison est documentée commejwts_blacklisted = 0en attendant un serviceJwtBlacklistService::blacklist_by_remote_identity_idcomplémentaire.
Tracking issue
À ouvrir au moment de la PR.
Score d'évaluation
| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 4 | Pattern handler de signal + trait verifier réutilisable. |
| Stabilité API | 4 | Additif ; sémantique de Failed change mais aucun consommateur public ne s'en sert. |
| Performance | 4 | Backoff exponentiel borné, lookups indexés (RFC 0011). |
| Sécurité | 3 | Trait verifier permet l'évolution propre, mais l'AlwaysAcceptVerifier doit être explicitement remplacé en production. |
| Complexité d'implémentation | 3 | ~700 lignes (service + trait + extension worker + 12 tests). |
| Maintenance | 4 | Pas de nouvelle dépendance externe. |
| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide ; production-readiness explicit dans la rustdoc d'AlwaysAcceptVerifier. |
| Total | 26/35 | ≥ 25 → Accepted |
GitRust