Skip to main content

nodedb_types/sync/
violation.rs

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