nodedb-types 0.0.0-beta.1

Portable type definitions shared between NodeDB Origin and NodeDB-Lite
Documentation
//! Violation types for DLQ classification.
//!
//! When a sync delta is rejected, the `ViolationType` categorizes *why* it
//! was rejected. This is stored in the DLQ on the Origin for forensic review
//! and is separate from `CompensationHint` (which is what the edge sees).

use serde::{Deserialize, Serialize};

/// Why a sync delta was placed in the Dead-Letter Queue.
///
/// Used on the Origin side for audit/forensics. The edge never sees this
/// directly — it only receives `CompensationHint` (which may be generic
/// for security reasons).
#[derive(
    Debug,
    Clone,
    PartialEq,
    Eq,
    Serialize,
    Deserialize,
    rkyv::Archive,
    rkyv::Serialize,
    rkyv::Deserialize,
)]
pub enum ViolationType {
    /// RLS write policy rejected the delta.
    RlsPolicyViolation { policy_name: String },
    /// UNIQUE constraint violation.
    UniqueViolation { field: String, value: String },
    /// Foreign key reference missing.
    ForeignKeyMissing { referenced_id: String },
    /// Permission denied (no write access to target resource).
    PermissionDenied,
    /// Rate limit exceeded for this session.
    RateLimited,
    /// JWT token expired during active session.
    TokenExpired,
    /// Schema validation failed.
    SchemaViolation { field: String, reason: String },
    /// Generic constraint violation (catch-all).
    ConstraintViolation { detail: String },
}

impl std::fmt::Display for ViolationType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::RlsPolicyViolation { policy_name } => {
                write!(f, "rls_policy:{policy_name}")
            }
            Self::UniqueViolation { field, value } => {
                write!(f, "unique:{field}={value}")
            }
            Self::ForeignKeyMissing { referenced_id } => {
                write!(f, "fk_missing:{referenced_id}")
            }
            Self::PermissionDenied => write!(f, "permission_denied"),
            Self::RateLimited => write!(f, "rate_limited"),
            Self::TokenExpired => write!(f, "token_expired"),
            Self::SchemaViolation { field, reason } => {
                write!(f, "schema:{field}={reason}")
            }
            Self::ConstraintViolation { detail } => write!(f, "constraint:{detail}"),
        }
    }
}

impl ViolationType {
    /// Convert a violation to the corresponding `CompensationHint` for the edge.
    ///
    /// Some violations map to a generic hint (e.g., RLS → PermissionDenied)
    /// to avoid leaking security-sensitive information to untrusted edges.
    pub fn to_compensation_hint(&self) -> super::compensation::CompensationHint {
        use super::compensation::CompensationHint;
        match self {
            Self::UniqueViolation { field, value } => CompensationHint::UniqueViolation {
                field: field.clone(),
                conflicting_value: value.clone(),
            },
            Self::ForeignKeyMissing { referenced_id } => CompensationHint::ForeignKeyMissing {
                referenced_id: referenced_id.clone(),
            },
            Self::RateLimited => CompensationHint::RateLimited {
                retry_after_ms: 5000,
            },
            // Security-sensitive violations all map to generic PermissionDenied.
            Self::RlsPolicyViolation { .. } | Self::PermissionDenied | Self::TokenExpired => {
                CompensationHint::PermissionDenied
            }
            Self::SchemaViolation { field, reason } => CompensationHint::SchemaViolation {
                field: field.clone(),
                reason: reason.clone(),
            },
            Self::ConstraintViolation { detail } => CompensationHint::Custom {
                constraint: "constraint".into(),
                detail: detail.clone(),
            },
        }
    }
}

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

    #[test]
    fn violation_display() {
        assert_eq!(
            ViolationType::PermissionDenied.to_string(),
            "permission_denied"
        );
        assert_eq!(ViolationType::RateLimited.to_string(), "rate_limited");
        assert_eq!(
            ViolationType::UniqueViolation {
                field: "email".into(),
                value: "x@y.com".into()
            }
            .to_string(),
            "unique:email=x@y.com"
        );
    }

    #[test]
    fn rls_violation_maps_to_permission_denied() {
        let v = ViolationType::RlsPolicyViolation {
            policy_name: "user_write_own".into(),
        };
        let hint = v.to_compensation_hint();
        // RLS details are NOT leaked to the edge.
        assert!(matches!(
            hint,
            super::super::compensation::CompensationHint::PermissionDenied
        ));
    }

    #[test]
    fn unique_violation_preserves_details() {
        let v = ViolationType::UniqueViolation {
            field: "username".into(),
            value: "alice".into(),
        };
        let hint = v.to_compensation_hint();
        match hint {
            super::super::compensation::CompensationHint::UniqueViolation {
                field,
                conflicting_value,
            } => {
                assert_eq!(field, "username");
                assert_eq!(conflicting_value, "alice");
            }
            _ => panic!("expected UniqueViolation hint"),
        }
    }

    #[test]
    fn token_expired_maps_to_permission_denied() {
        let hint = ViolationType::TokenExpired.to_compensation_hint();
        assert!(matches!(
            hint,
            super::super::compensation::CompensationHint::PermissionDenied
        ));
    }
}