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    zerompk::ToMessagePack,
27    zerompk::FromMessagePack,
28)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum ViolationType {
32    /// RLS write policy rejected the delta.
33    #[serde(rename = "rls_policy_violation")]
34    RlsPolicyViolation { policy_name: String },
35    /// UNIQUE constraint violation.
36    #[serde(rename = "unique_violation")]
37    UniqueViolation { field: String, value: String },
38    /// Foreign key reference missing.
39    #[serde(rename = "foreign_key_missing")]
40    ForeignKeyMissing { referenced_id: String },
41    /// Permission denied (no write access to target resource).
42    #[serde(rename = "permission_denied")]
43    PermissionDenied,
44    /// Rate limit exceeded for this session.
45    #[serde(rename = "rate_limited")]
46    RateLimited,
47    /// JWT token expired during active session.
48    #[serde(rename = "token_expired")]
49    TokenExpired,
50    /// Schema validation failed.
51    #[serde(rename = "schema_violation")]
52    SchemaViolation { field: String, reason: String },
53    /// Generic constraint violation (catch-all).
54    #[serde(rename = "constraint_violation")]
55    ConstraintViolation { detail: String },
56    /// Delta admitted against a constraint-set version this replica has not
57    /// installed yet (constraint install still propagating on the data Raft
58    /// log). Transient: the client should retry after the install catches up.
59    #[serde(rename = "constraint_version_pending")]
60    ConstraintVersionPending {
61        collection: String,
62        required: u64,
63        installed: u64,
64    },
65}
66
67/// Suggested delay (milliseconds) before re-pushing a delta rejected by
68/// `ConstraintVersionPending`. Matches the ~1s tick of the constraint
69/// reconcile loop, so a single retry after this delay typically finds the
70/// install has caught up.
71pub const CONSTRAINT_RETRY_AFTER_MS: u64 = 1000;
72
73impl std::fmt::Display for ViolationType {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::RlsPolicyViolation { policy_name } => {
77                write!(f, "rls_policy:{policy_name}")
78            }
79            Self::UniqueViolation { field, value } => {
80                write!(f, "unique:{field}={value}")
81            }
82            Self::ForeignKeyMissing { referenced_id } => {
83                write!(f, "fk_missing:{referenced_id}")
84            }
85            Self::PermissionDenied => write!(f, "permission_denied"),
86            Self::RateLimited => write!(f, "rate_limited"),
87            Self::TokenExpired => write!(f, "token_expired"),
88            Self::SchemaViolation { field, reason } => {
89                write!(f, "schema:{field}={reason}")
90            }
91            Self::ConstraintViolation { detail } => write!(f, "constraint:{detail}"),
92            Self::ConstraintVersionPending {
93                collection,
94                required,
95                installed,
96            } => {
97                write!(
98                    f,
99                    "constraint_version_pending:{collection} req={required} installed={installed}"
100                )
101            }
102        }
103    }
104}
105
106impl ViolationType {
107    /// Convert a violation to the corresponding `CompensationHint` for the edge.
108    ///
109    /// Some violations map to a generic hint (e.g., RLS → PermissionDenied)
110    /// to avoid leaking security-sensitive information to untrusted edges.
111    pub fn to_compensation_hint(&self) -> super::compensation::CompensationHint {
112        use super::compensation::CompensationHint;
113        match self {
114            Self::UniqueViolation { field, value } => CompensationHint::UniqueViolation {
115                field: field.clone(),
116                conflicting_value: value.clone(),
117            },
118            Self::ForeignKeyMissing { referenced_id } => CompensationHint::ForeignKeyMissing {
119                referenced_id: referenced_id.clone(),
120            },
121            Self::RateLimited => CompensationHint::RateLimited {
122                retry_after_ms: 5000,
123            },
124            // Security-sensitive violations all map to generic PermissionDenied.
125            Self::RlsPolicyViolation { .. } | Self::PermissionDenied | Self::TokenExpired => {
126                CompensationHint::PermissionDenied
127            }
128            Self::SchemaViolation { field, reason } => CompensationHint::SchemaViolation {
129                field: field.clone(),
130                reason: reason.clone(),
131            },
132            Self::ConstraintViolation { detail } => CompensationHint::Custom {
133                constraint: "constraint".into(),
134                detail: detail.clone(),
135            },
136            Self::ConstraintVersionPending { .. } => CompensationHint::Retry {
137                retry_after_ms: CONSTRAINT_RETRY_AFTER_MS,
138            },
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn violation_display() {
149        assert_eq!(
150            ViolationType::PermissionDenied.to_string(),
151            "permission_denied"
152        );
153        assert_eq!(ViolationType::RateLimited.to_string(), "rate_limited");
154        assert_eq!(
155            ViolationType::UniqueViolation {
156                field: "email".into(),
157                value: "x@y.com".into()
158            }
159            .to_string(),
160            "unique:email=x@y.com"
161        );
162    }
163
164    #[test]
165    fn rls_violation_maps_to_permission_denied() {
166        let v = ViolationType::RlsPolicyViolation {
167            policy_name: "user_write_own".into(),
168        };
169        let hint = v.to_compensation_hint();
170        // RLS details are NOT leaked to the edge.
171        assert!(matches!(
172            hint,
173            super::super::compensation::CompensationHint::PermissionDenied
174        ));
175    }
176
177    #[test]
178    fn unique_violation_preserves_details() {
179        let v = ViolationType::UniqueViolation {
180            field: "username".into(),
181            value: "alice".into(),
182        };
183        let hint = v.to_compensation_hint();
184        match hint {
185            super::super::compensation::CompensationHint::UniqueViolation {
186                field,
187                conflicting_value,
188            } => {
189                assert_eq!(field, "username");
190                assert_eq!(conflicting_value, "alice");
191            }
192            _ => panic!("expected UniqueViolation hint"),
193        }
194    }
195
196    #[test]
197    fn token_expired_maps_to_permission_denied() {
198        let hint = ViolationType::TokenExpired.to_compensation_hint();
199        assert!(matches!(
200            hint,
201            super::super::compensation::CompensationHint::PermissionDenied
202        ));
203    }
204
205    #[test]
206    fn constraint_version_pending_maps_to_retry() {
207        let v = ViolationType::ConstraintVersionPending {
208            collection: "orders".into(),
209            required: 3,
210            installed: 2,
211        };
212        let hint = v.to_compensation_hint();
213        assert!(matches!(
214            hint,
215            super::super::compensation::CompensationHint::Retry { .. }
216        ));
217    }
218}