Skip to main content

nodedb_types/sync/
violation.rs

1//! Violation types for DLQ classification.
2//!
3//! When a sync delta is rejected, the `ViolationType` categorizes *why* it
4//! was rejected. This is stored in the DLQ on the Origin for forensic review
5//! and is separate from `CompensationHint` (which is what the edge sees).
6
7use serde::{Deserialize, Serialize};
8
9/// Why a sync delta was placed in the Dead-Letter Queue.
10///
11/// Used on the Origin side for audit/forensics. The edge never sees this
12/// directly — it only receives `CompensationHint` (which may be generic
13/// for security reasons).
14#[derive(
15    Debug,
16    Clone,
17    PartialEq,
18    Eq,
19    Serialize,
20    Deserialize,
21    rkyv::Archive,
22    rkyv::Serialize,
23    rkyv::Deserialize,
24)]
25pub enum ViolationType {
26    /// RLS write policy rejected the delta.
27    RlsPolicyViolation { policy_name: String },
28    /// UNIQUE constraint violation.
29    UniqueViolation { field: String, value: String },
30    /// Foreign key reference missing.
31    ForeignKeyMissing { referenced_id: String },
32    /// Permission denied (no write access to target resource).
33    PermissionDenied,
34    /// Rate limit exceeded for this session.
35    RateLimited,
36    /// JWT token expired during active session.
37    TokenExpired,
38    /// Schema validation failed.
39    SchemaViolation { field: String, reason: String },
40    /// Generic constraint violation (catch-all).
41    ConstraintViolation { detail: String },
42}
43
44impl std::fmt::Display for ViolationType {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::RlsPolicyViolation { policy_name } => {
48                write!(f, "rls_policy:{policy_name}")
49            }
50            Self::UniqueViolation { field, value } => {
51                write!(f, "unique:{field}={value}")
52            }
53            Self::ForeignKeyMissing { referenced_id } => {
54                write!(f, "fk_missing:{referenced_id}")
55            }
56            Self::PermissionDenied => write!(f, "permission_denied"),
57            Self::RateLimited => write!(f, "rate_limited"),
58            Self::TokenExpired => write!(f, "token_expired"),
59            Self::SchemaViolation { field, reason } => {
60                write!(f, "schema:{field}={reason}")
61            }
62            Self::ConstraintViolation { detail } => write!(f, "constraint:{detail}"),
63        }
64    }
65}
66
67impl ViolationType {
68    /// Convert a violation to the corresponding `CompensationHint` for the edge.
69    ///
70    /// Some violations map to a generic hint (e.g., RLS → PermissionDenied)
71    /// to avoid leaking security-sensitive information to untrusted edges.
72    pub fn to_compensation_hint(&self) -> super::compensation::CompensationHint {
73        use super::compensation::CompensationHint;
74        match self {
75            Self::UniqueViolation { field, value } => CompensationHint::UniqueViolation {
76                field: field.clone(),
77                conflicting_value: value.clone(),
78            },
79            Self::ForeignKeyMissing { referenced_id } => CompensationHint::ForeignKeyMissing {
80                referenced_id: referenced_id.clone(),
81            },
82            Self::RateLimited => CompensationHint::RateLimited {
83                retry_after_ms: 5000,
84            },
85            // Security-sensitive violations all map to generic PermissionDenied.
86            Self::RlsPolicyViolation { .. } | Self::PermissionDenied | Self::TokenExpired => {
87                CompensationHint::PermissionDenied
88            }
89            Self::SchemaViolation { field, reason } => CompensationHint::SchemaViolation {
90                field: field.clone(),
91                reason: reason.clone(),
92            },
93            Self::ConstraintViolation { detail } => CompensationHint::Custom {
94                constraint: "constraint".into(),
95                detail: detail.clone(),
96            },
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn violation_display() {
107        assert_eq!(
108            ViolationType::PermissionDenied.to_string(),
109            "permission_denied"
110        );
111        assert_eq!(ViolationType::RateLimited.to_string(), "rate_limited");
112        assert_eq!(
113            ViolationType::UniqueViolation {
114                field: "email".into(),
115                value: "x@y.com".into()
116            }
117            .to_string(),
118            "unique:email=x@y.com"
119        );
120    }
121
122    #[test]
123    fn rls_violation_maps_to_permission_denied() {
124        let v = ViolationType::RlsPolicyViolation {
125            policy_name: "user_write_own".into(),
126        };
127        let hint = v.to_compensation_hint();
128        // RLS details are NOT leaked to the edge.
129        assert!(matches!(
130            hint,
131            super::super::compensation::CompensationHint::PermissionDenied
132        ));
133    }
134
135    #[test]
136    fn unique_violation_preserves_details() {
137        let v = ViolationType::UniqueViolation {
138            field: "username".into(),
139            value: "alice".into(),
140        };
141        let hint = v.to_compensation_hint();
142        match hint {
143            super::super::compensation::CompensationHint::UniqueViolation {
144                field,
145                conflicting_value,
146            } => {
147                assert_eq!(field, "username");
148                assert_eq!(conflicting_value, "alice");
149            }
150            _ => panic!("expected UniqueViolation hint"),
151        }
152    }
153
154    #[test]
155    fn token_expired_maps_to_permission_denied() {
156        let hint = ViolationType::TokenExpired.to_compensation_hint();
157        assert!(matches!(
158            hint,
159            super::super::compensation::CompensationHint::PermissionDenied
160        ));
161    }
162}