road-runner-common 0.31.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! The one rule every governance consumer applies before acting on a proposal
//! it re-fetched from cex-policy: **only an `Approved` proposal for an action
//! this consumer owns, with a readable payload, is ever applied.**
//!
//! A `QuorumReached{Approved}` event is a hint, not an authority. The authority
//! is the proposal record cex-policy serves, and a consumer must read the
//! status from there: a replayed, misrouted or forged event for a proposal that
//! is still `Pending`, or that was `Rejected`, `Expired`, `Cancelled` or
//! `Superseded`, must never change anything. A proposal cex-policy applied
//! without votes (`decision = direct`, the "quorum required" switch off) or
//! from the setup manifest (`decision = setup`) is recorded `Approved` too, so
//! this one status check covers every legitimate path.
//!
//! [`GovernanceProposal::ensure_applicable`] returns the payload to apply, or a
//! typed [`ProposalNotApplicable`] that tells the handler what to do with the
//! event:
//!
//! | outcome                                 | handler returns | log            |
//! |-----------------------------------------|-----------------|----------------|
//! | [`ProposalNotApplicable::OtherAction`]  | `Ok(())`        | debug          |
//! | [`ProposalNotApplicable::NotApproved`]  | `Ok(())`        | `security`     |
//! | [`ProposalNotApplicable::PayloadUnreadable`] | `Ok(())`   | `security`     |
//! | [`ProposalNotApplicable::ActionUnresolved`]  | `Err(_)`   | warn (retried) |
//!
//! Every variant but `ActionUnresolved` is a definitive answer from cex-policy
//! (a redelivery would read the same record), so the event is acknowledged and
//! nothing is applied. `ActionUnresolved` means cex-policy could not resolve
//! the proposal's governing policy while answering; nothing is known about
//! whether this consumer owns it, so the offset is held.

use super::client::GovernanceProposal;

/// The status string cex-policy's `GetProposal` returns for an approved
/// proposal (the serde name of `ProposalStatus::Approved`). Quorum, direct
/// (quorum-optional ruleset) and setup-manifest approvals all carry it.
pub const PROPOSAL_STATUS_APPROVED: &str = "Approved";

/// Why a re-fetched proposal must not be applied. See the module docs for how
/// a handler answers each variant.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ProposalNotApplicable {
    /// The proposal governs an action this consumer does not apply. Routine:
    /// every consumer sees every `QuorumReached` on the governance stream.
    #[error("proposal {proposal_id} governs {actual}, not {expected:?}")]
    OtherAction {
        proposal_id: String,
        expected: Vec<String>,
        actual: String,
    },

    /// The proposal is for this consumer's action but cex-policy does not
    /// record it as approved (`Pending`, `Rejected`, `Expired`, `Cancelled`,
    /// `Superseded`, or anything unknown). Never apply; log on the `security`
    /// target — an approval event for it should not exist.
    #[error("proposal {proposal_id} for {action} is {status}, not Approved")]
    NotApproved {
        proposal_id: String,
        action: String,
        status: String,
    },

    /// The proposal is approved and for this consumer's action, but its stored
    /// payload is not valid JSON. A retry reads the same bytes; never apply.
    #[error("proposal {proposal_id} for {action} has no readable payload")]
    PayloadUnreadable { proposal_id: String, action: String },

    /// cex-policy answered without the proposal's governing action (its
    /// policy lookup failed while serving the read). Not an answer about the
    /// proposal: hold the event and retry.
    #[error("proposal {proposal_id} came back without a resolved action")]
    ActionUnresolved { proposal_id: String },
}

impl ProposalNotApplicable {
    /// `true` when cex-policy gave a definitive answer and the triggering event
    /// must be acknowledged (`Ok(())`) without applying anything. `false` only
    /// for [`Self::ActionUnresolved`], which must hold the event (`Err`).
    pub fn is_definitive(&self) -> bool {
        !matches!(self, Self::ActionUnresolved { .. })
    }

    /// `true` when the refusal concerns a proposal this consumer owns and must
    /// be logged on the `security` target: a non-approved proposal reached an
    /// apply path, or an approved one cannot be read.
    pub fn is_security_relevant(&self) -> bool {
        matches!(
            self,
            Self::NotApproved { .. } | Self::PayloadUnreadable { .. }
        )
    }

    /// Stable, low-cardinality label for logs and metrics.
    pub fn reason(&self) -> &'static str {
        match self {
            Self::OtherAction { .. } => "other_action",
            Self::NotApproved { .. } => "not_approved",
            Self::PayloadUnreadable { .. } => "payload_unreadable",
            Self::ActionUnresolved { .. } => "action_unresolved",
        }
    }
}

impl GovernanceProposal {
    /// Whether cex-policy records this proposal as approved.
    pub fn is_approved(&self) -> bool {
        self.status == PROPOSAL_STATUS_APPROVED
    }

    /// Check, in this order, that the proposal's governing action resolved,
    /// that it is one of `expected_actions`, that it is `Approved`, and that
    /// its payload parsed. Returns the payload to validate and apply.
    ///
    /// Validating the payload's *content* stays with the consumer (it is
    /// action-specific); this only guarantees the consumer never reaches that
    /// step for a proposal nobody approved.
    pub fn ensure_applicable(
        &self,
        expected_actions: &[&str],
    ) -> Result<&serde_json::Value, ProposalNotApplicable> {
        let Some(action) = self.action.as_deref() else {
            return Err(ProposalNotApplicable::ActionUnresolved {
                proposal_id: self.id.clone(),
            });
        };
        if !expected_actions.contains(&action) {
            return Err(ProposalNotApplicable::OtherAction {
                proposal_id: self.id.clone(),
                expected: expected_actions.iter().map(|a| (*a).to_string()).collect(),
                actual: action.to_string(),
            });
        }
        if !self.is_approved() {
            return Err(ProposalNotApplicable::NotApproved {
                proposal_id: self.id.clone(),
                action: action.to_string(),
                status: self.status.clone(),
            });
        }
        self.payload
            .as_ref()
            .ok_or_else(|| ProposalNotApplicable::PayloadUnreadable {
                proposal_id: self.id.clone(),
                action: action.to_string(),
            })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn proposal(
        action: Option<&str>,
        status: &str,
        payload: Option<serde_json::Value>,
    ) -> GovernanceProposal {
        GovernanceProposal {
            id: "p-1".to_string(),
            policy_id: "pol-1".to_string(),
            action: action.map(str::to_string),
            status: status.to_string(),
            payload,
        }
    }

    #[test]
    fn approved_proposal_for_an_expected_action_yields_its_payload() {
        let p = proposal(Some("AuthRuleset"), "Approved", Some(json!({"v": 1})));
        assert_eq!(p.ensure_applicable(&["AuthRuleset"]), Ok(&json!({"v": 1})));
        assert!(p.is_approved());
    }

    #[test]
    fn any_listed_action_is_accepted() {
        let p = proposal(Some("Delist"), "Approved", Some(json!({})));
        assert!(p.ensure_applicable(&["Listing", "Delist"]).is_ok());
    }

    #[test]
    fn every_non_approved_status_is_refused_definitively_and_flagged() {
        for status in [
            "Pending",
            "Rejected",
            "Expired",
            "Cancelled",
            "Superseded",
            "approved",
            "",
        ] {
            let p = proposal(Some("AuthRuleset"), status, Some(json!({"v": 1})));
            let err = p.ensure_applicable(&["AuthRuleset"]).unwrap_err();
            assert_eq!(
                err,
                ProposalNotApplicable::NotApproved {
                    proposal_id: "p-1".into(),
                    action: "AuthRuleset".into(),
                    status: status.into(),
                },
                "status {status:?}"
            );
            assert!(err.is_definitive());
            assert!(err.is_security_relevant());
            assert_eq!(err.reason(), "not_approved");
        }
    }

    #[test]
    fn another_action_is_routine_and_checked_before_status() {
        // A pending proposal for someone else's action is not a security event
        // for this consumer: it is simply not ours.
        let p = proposal(Some("FeeChange"), "Pending", Some(json!({})));
        let err = p.ensure_applicable(&["AuthRuleset"]).unwrap_err();
        assert!(matches!(err, ProposalNotApplicable::OtherAction { .. }));
        assert!(err.is_definitive());
        assert!(!err.is_security_relevant());
    }

    #[test]
    fn unresolved_action_holds_the_event() {
        let p = proposal(None, "Approved", Some(json!({})));
        let err = p.ensure_applicable(&["AuthRuleset"]).unwrap_err();
        assert_eq!(
            err,
            ProposalNotApplicable::ActionUnresolved {
                proposal_id: "p-1".into()
            }
        );
        assert!(!err.is_definitive());
        assert!(!err.is_security_relevant());
    }

    #[test]
    fn approved_proposal_without_a_payload_is_refused() {
        let p = proposal(Some("AuthRuleset"), "Approved", None);
        let err = p.ensure_applicable(&["AuthRuleset"]).unwrap_err();
        assert!(matches!(
            err,
            ProposalNotApplicable::PayloadUnreadable { .. }
        ));
        assert!(err.is_definitive());
        assert!(err.is_security_relevant());
    }
}