0010-fediverse-lifecycle-signals.md 11054 octets

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-core qui doit appliquer les signaux ActivityPub Update{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 /inbox côté serveur — un follow-up RFC routes-only ajoutera l'endpoint AP unique qui dispatche vers le service selon le type de l'activité.
  • Pas de UI HTML des "apps connectées" / sessions remote admin — rustwarden-ui Phase 4 finale.
  • Pas d'implémentation cryptographique HTTP-Sig dans cette RFC. Le trait SignatureVerifier est livré, et l'impl AlwaysAcceptVerifier (test/dev) ; la HttpSigVerifier ré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 + impl NoopDelivery (RFC 0011), HttpSignedDelivery ré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 :

  1. SELECT pending row dont next_retry_at IS NULL OR next_retry_at <= NOW().
  2. Tente la livraison via OutboundDelivery::deliver.
  3. Sur succès → Delivered.
  4. Sur échec :
    • Si attempts + 1 >= max_attempts → Dead.
    • Sinon → Pending (sic — pour rester éligible) avec next_retry_at = NOW() + next_retry_offset(attempts + 1). Le statut reste pending tout en étant gated par le timer ; le scan WHERE status='pending' AND next_retry_at <= NOW() ignore les rows en attente.

Note : dans le slice livré par RFC 0011 le statut Failed était terminal sans retry. RFC 0010 le rend transitoire ; les rows Failed créé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::Failed change (transitoire, plus terminal). Les rows Failed legacy 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 :

  1. next_retry_offset_grows_then_caps — séquence 5min, 30min, 2h, 6h, 24h, 48h, 48h….
  2. 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.
  • Failed reste-t-il un état distinct ? : non utilisé dans le ladder de RFC 0010 (on bascule directement pending avec next_retry_at), mais la variante reste pour permettre à un futur opérateur de marquer manuellement une row morte.
  • apply_delete_actor doit-il blacklist les JWT actifs ? : oui, via JwtBlacklistService. La méthode actuelle requiert un token concret ; le service est étendu (ou contourné via une nouvelle méthode blacklist_for_remote_identity) — la livraison est documentée comme jwts_blacklisted = 0 en attendant un service JwtBlacklistService::blacklist_by_remote_identity_id complémentaire.

Tracking issue

À ouvrir au moment de la PR.


Score d'évaluation

CritèreNote (1-5)Commentaire
Généralité4Pattern handler de signal + trait verifier réutilisable.
Stabilité API4Additif ; sémantique de Failed change mais aucun consommateur public ne s'en sert.
Performance4Backoff exponentiel borné, lookups indexés (RFC 0011).
Sécurité3Trait verifier permet l'évolution propre, mais l'AlwaysAcceptVerifier doit être explicitement remplacé en production.
Complexité d'implémentation3~700 lignes (service + trait + extension worker + 12 tests).
Maintenance4Pas de nouvelle dépendance externe.
Documentation4Rustdoc EN, doctest, CHANGELOG, migration guide ; production-readiness explicit dans la rustdoc d'AlwaysAcceptVerifier.
Total26/35≥ 25 → Accepted