Skip to main content

heddle_api/v2/
notifications.rs

1//! Portable notification delivery gates. Hosts must run these before either
2//! settings replacement or capability-authorized unsubscribe, then independently
3//! check authorization, destination verification and expected_version. These
4//! gates complement remaining stored-settings validation: hosts must also check
5//! timezone, selectors, duplicate overrides and input budgets.
6
7use crate::heddle::api::common::{CallFailureCode, ErrorReason};
8use crate::heddle::api::v1alpha2::{
9    EffectiveDelivery, NotificationPreferences, NotificationRule,
10    SetNotificationPreferencesRequest, SpoolRef, UnsubscribeNotificationsRequest,
11    effective_delivery::Source,
12    notification_rule::{Channel, Delivery},
13};
14
15pub const MAX_EFFECTIVE_DELIVERIES: usize = 4096;
16pub const MAX_NOTIFICATION_PREFERENCES_BYTES: usize = 1024 * 1024;
17pub const LOCKED_EMAIL_KINDS: &[&str] = &["account_security", "security_surface"];
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
20pub enum NotificationValidationError {
21    #[error("unknown channel or unspecified/unknown delivery")]
22    InvalidRule,
23    #[error("DIGEST requires the email channel")]
24    DigestRequiresEmail,
25    #[error("digest interval must be zero, one hour, one day or one week with zero nanos")]
26    InvalidDigestInterval,
27    #[error("security/recovery email must remain IMMEDIATE")]
28    LockedEmail,
29    #[error("unsubscribe requires DISABLED delivery")]
30    UnsubscribeDelivery,
31    #[error("notification preferences exceed the read projection bound")]
32    ProjectionTooLarge,
33    #[error("ancestor chain must be complete, acyclic and within the host's 64/128-node bound")]
34    InvalidAncestry,
35    #[error("invalid or unauthorized delivery provenance")]
36    InvalidSource,
37}
38
39impl NotificationValidationError {
40    pub const fn code(self) -> CallFailureCode {
41        match self {
42            Self::LockedEmail | Self::InvalidAncestry => CallFailureCode::FailedPrecondition,
43            Self::ProjectionTooLarge => CallFailureCode::ResourceExhausted,
44            _ => CallFailureCode::InvalidArgument,
45        }
46    }
47
48    pub const fn reason(self) -> ErrorReason {
49        match self {
50            Self::LockedEmail | Self::InvalidAncestry => ErrorReason::PolicyDenied,
51            Self::ProjectionTooLarge => ErrorReason::QuotaExceeded,
52            _ => ErrorReason::FieldInvalid,
53        }
54    }
55}
56
57/// Check selector/channel semantics. Wildcard OFF rules that include locked
58/// email kinds are rejected rather than silently overriding the user's request.
59/// After enum validation, the email lock takes precedence over email-only DIGEST.
60pub fn validate_notification_rule(
61    rule: &NotificationRule,
62) -> Result<(), NotificationValidationError> {
63    let channel =
64        Channel::try_from(rule.channel).map_err(|_| NotificationValidationError::InvalidRule)?;
65    let delivery =
66        Delivery::try_from(rule.delivery).map_err(|_| NotificationValidationError::InvalidRule)?;
67    if delivery == Delivery::Unspecified {
68        return Err(NotificationValidationError::InvalidRule);
69    }
70    let matches_locked = rule.kind.is_empty()
71        || rule.kind == "*"
72        || LOCKED_EMAIL_KINDS.contains(&rule.kind.as_str());
73    if matches_locked
74        && matches!(channel, Channel::Email | Channel::Unspecified)
75        && delivery != Delivery::Immediate
76    {
77        return Err(NotificationValidationError::LockedEmail);
78    }
79    if delivery == Delivery::Digest && channel != Channel::Email {
80        return Err(NotificationValidationError::DigestRequiresEmail);
81    }
82    Ok(())
83}
84
85pub fn validate_notification_preferences_write(
86    request: &SetNotificationPreferencesRequest,
87) -> Result<(), NotificationValidationError> {
88    let preferences = request
89        .preferences
90        .as_ref()
91        .ok_or(NotificationValidationError::InvalidRule)?;
92    if let Some(interval) = &preferences.digest_interval {
93        validate_digest_interval(interval)?;
94    }
95    for digest_override in &preferences.digest_overrides {
96        let interval = digest_override
97            .digest_interval
98            .as_ref()
99            .ok_or(NotificationValidationError::InvalidDigestInterval)?;
100        validate_digest_interval(interval)?;
101    }
102    for rule in &preferences.rules {
103        validate_notification_rule(rule)?;
104    }
105    Ok(())
106}
107
108/// Settings write gate with the resolved system-root identity. Hosts must use
109/// this gate (or equivalent scope validation) to forbid system-root rules.
110pub fn validate_notification_preferences_write_for_system_root(
111    request: &SetNotificationPreferencesRequest,
112    system_root: &SpoolRef,
113) -> Result<(), NotificationValidationError> {
114    validate_notification_preferences_write(request)?;
115    if request.preferences.as_ref().is_some_and(|preferences| {
116        preferences
117            .rules
118            .iter()
119            .any(|rule| rule.spool.as_ref() == Some(system_root))
120    }) {
121        return Err(NotificationValidationError::InvalidRule);
122    }
123    Ok(())
124}
125
126fn validate_digest_interval(
127    interval: &prost_types::Duration,
128) -> Result<(), NotificationValidationError> {
129    if interval.nanos != 0 || !matches!(interval.seconds, 0 | 3600 | 86400 | 604800) {
130        return Err(NotificationValidationError::InvalidDigestInterval);
131    }
132    Ok(())
133}
134
135pub fn validate_unsubscribe_notifications(
136    request: &UnsubscribeNotificationsRequest,
137) -> Result<(), NotificationValidationError> {
138    for rule in &request.rules {
139        validate_notification_rule(rule)?;
140        if rule.delivery != Delivery::Disabled as i32 {
141            return Err(NotificationValidationError::UnsubscribeDelivery);
142        }
143    }
144    Ok(())
145}
146
147/// Validate the documented bound before emitting a preferences read. Stored
148/// rules and string lengths count toward the byte cap, not only resolved cells.
149pub fn validate_notification_preferences_bound(
150    preferences: &NotificationPreferences,
151) -> Result<(), NotificationValidationError> {
152    use prost::Message;
153    if preferences.effective_delivery.len() > MAX_EFFECTIVE_DELIVERIES
154        || preferences.encoded_len() > MAX_NOTIFICATION_PREFERENCES_BYTES
155    {
156        return Err(NotificationValidationError::ProjectionTooLarge);
157    }
158    Ok(())
159}
160
161/// Maximum nodes, including the event spool. Hosts with a 64-node bound pass 64.
162pub const MAX_NOTIFICATION_ANCESTORS: usize = 128;
163
164/// Trusted host-loaded ancestry, nearest first. The loader must verify parent
165/// links and completeness before calling; never build this from client input or
166/// omit unreadable ancestors. The optional shared system root is last.
167#[derive(Clone, Debug)]
168pub struct NotificationAncestor {
169    pub spool: SpoolRef,
170    pub readable: bool,
171    pub system_root: bool,
172}
173
174fn validate_ancestry(
175    cell: &EffectiveDelivery,
176    ancestors: &[NotificationAncestor],
177    ancestor_limit: usize,
178) -> Result<(), NotificationValidationError> {
179    if !matches!(ancestor_limit, 64 | MAX_NOTIFICATION_ANCESTORS)
180        || ancestors.len() > ancestor_limit
181        || cell.spool.as_ref() != ancestors.first().map(|level| &level.spool)
182        || ancestors.iter().enumerate().any(|(i, level)| {
183            level.spool.id.is_empty()
184                || ancestors[..i]
185                    .iter()
186                    .any(|other| other.spool == level.spool)
187                || (level.system_root && (i == 0 || i + 1 != ancestors.len()))
188        })
189    {
190        return Err(NotificationValidationError::InvalidAncestry);
191    }
192    if ancestors.first().is_some_and(|level| !level.readable) {
193        return Err(NotificationValidationError::InvalidSource);
194    }
195    Ok(())
196}
197
198/// Resolve one cell from validated settings and trusted complete ancestry.
199/// Defaults and effective email cadence are host-owned inputs. Routing and read
200/// provenance use the same result; unreadability never changes selection.
201/// This plans a projection; hosts still enforce authorization and persist writes.
202pub fn resolve_notification_delivery(
203    rules: &[NotificationRule],
204    cell: &EffectiveDelivery,
205    ancestors: &[NotificationAncestor],
206    ancestor_limit: usize,
207    default_delivery: Delivery,
208    digest_enabled: bool,
209) -> Result<EffectiveDelivery, NotificationValidationError> {
210    validate_ancestry(cell, ancestors, ancestor_limit)?;
211    let channel =
212        Channel::try_from(cell.channel).map_err(|_| NotificationValidationError::InvalidRule)?;
213    if channel == Channel::Unspecified
214        || matches!(cell.kind.as_str(), "" | "*")
215        || !matches!(cell.actor_origin.as_str(), "" | "human" | "agent")
216        || default_delivery == Delivery::Unspecified
217        || (default_delivery == Delivery::Digest && channel != Channel::Email)
218    {
219        return Err(NotificationValidationError::InvalidRule);
220    }
221    for rule in rules {
222        validate_notification_rule(rule)?;
223        if ancestors
224            .iter()
225            .any(|level| level.system_root && rule.spool.as_ref() == Some(&level.spool))
226        {
227            return Err(NotificationValidationError::InvalidRule);
228        }
229    }
230    let mut selected = None;
231    for (i, level) in ancestors
232        .iter()
233        .enumerate()
234        .filter(|(_, level)| !level.system_root)
235    {
236        if let Some(rule) = best_notification_rule(rules, cell, Some(&level.spool)) {
237            let source = if i == 0 {
238                Source::Rule
239            } else {
240                Source::Inherited
241            };
242            let source_spool = (i > 0 && level.readable).then(|| level.spool.clone());
243            selected = Some((rule.delivery, source, source_spool));
244            break;
245        }
246    }
247    if selected.is_none() {
248        selected = best_notification_rule(rules, cell, None).map(|rule| {
249            (
250                rule.delivery,
251                if cell.spool.is_some() {
252                    Source::Account
253                } else {
254                    Source::Rule
255                },
256                None,
257            )
258        });
259    }
260    let (mut delivery, source, source_spool) =
261        selected.unwrap_or((default_delivery as i32, Source::Default, None));
262    let locked = channel == Channel::Email && LOCKED_EMAIL_KINDS.contains(&cell.kind.as_str());
263    if locked {
264        delivery = Delivery::Immediate as i32;
265    } else if delivery == Delivery::Digest as i32 && !digest_enabled {
266        delivery = Delivery::Disabled as i32;
267    }
268    Ok(EffectiveDelivery {
269        kind: cell.kind.clone(),
270        spool: cell.spool.clone(),
271        actor_origin: cell.actor_origin.clone(),
272        channel: cell.channel,
273        delivery,
274        source: source as i32,
275        locked,
276        source_spool,
277    })
278}
279
280fn best_notification_rule<'a>(
281    rules: &'a [NotificationRule],
282    cell: &EffectiveDelivery,
283    spool: Option<&SpoolRef>,
284) -> Option<&'a NotificationRule> {
285    let mut best: Option<(u8, &NotificationRule)> = None;
286    for rule in rules {
287        let exact_kind = !matches!(rule.kind.as_str(), "" | "*");
288        let exact_channel = rule.channel != Channel::Unspecified as i32;
289        let exact_origin = !matches!(rule.actor_origin.as_str(), "" | "any");
290        if rule.spool.as_ref() != spool
291            || (exact_kind && rule.kind != cell.kind)
292            || (exact_channel && rule.channel != cell.channel)
293            || (exact_origin && rule.actor_origin != cell.actor_origin)
294        {
295            continue;
296        }
297        let score = u8::from(exact_kind) * 4 + u8::from(exact_channel) * 2 + u8::from(exact_origin);
298        if best.is_none_or(|(previous, _)| score > previous) {
299            best = Some((score, rule));
300        }
301    }
302    best.map(|(_, rule)| rule)
303}
304
305/// Validate provenance before emitting a cell. Hosts must additionally compare
306/// against their resolved result: this gate checks disclosure, not rule storage.
307pub fn validate_effective_delivery_source(
308    cell: &EffectiveDelivery,
309    ancestors: &[NotificationAncestor],
310    ancestor_limit: usize,
311) -> Result<(), NotificationValidationError> {
312    validate_ancestry(cell, ancestors, ancestor_limit)?;
313    let valid = match Source::try_from(cell.source) {
314        Ok(Source::Rule | Source::Default) => cell.source_spool.is_none(),
315        Ok(Source::Account) => cell.spool.is_some() && cell.source_spool.is_none(),
316        Ok(Source::Inherited) => {
317            cell.spool.is_some()
318                && ancestors.iter().skip(1).any(|level| {
319                    !level.system_root
320                        && match &cell.source_spool {
321                            Some(spool) => level.readable && spool == &level.spool,
322                            None => !level.readable,
323                        }
324                })
325        }
326        _ => false,
327    };
328    if valid {
329        Ok(())
330    } else {
331        Err(NotificationValidationError::InvalidSource)
332    }
333}
334
335/// Choose scopes BEFORE expanding the kind/origin/channel matrix. The host
336/// supplies current readable IDs; rule-free descendants never enter this list.
337pub fn notification_projection_scopes(
338    request: &crate::heddle::api::v1alpha2::ObserveNotificationsRequest,
339    rules: &[NotificationRule],
340    readable: &[SpoolRef],
341) -> Result<Vec<Option<SpoolRef>>, NotificationValidationError> {
342    if let Some(spool) = &request.effective_delivery_spool {
343        if !request.include_preferences || spool.id.is_empty() || !readable.contains(spool) {
344            return Err(NotificationValidationError::InvalidSource);
345        }
346        return Ok(vec![Some(spool.clone())]);
347    }
348    let mut scopes = vec![None];
349    for rule in rules {
350        if let Some(spool) = &rule.spool
351            && readable.contains(spool)
352            && !scopes.contains(&Some(spool.clone()))
353        {
354            scopes.push(Some(spool.clone()));
355        }
356    }
357    Ok(scopes)
358}
359
360/// Plan an atomic replacement. Readability and stored settings must be loaded
361/// under the same lock/CAS as commit. Clear is caller-account authority only.
362/// Host validates storage quotas AFTER preservation, not projection budgets.
363pub fn replace_notification_preferences(
364    stored: &NotificationPreferences,
365    request: &SetNotificationPreferencesRequest,
366    readable: &[SpoolRef],
367) -> Result<NotificationPreferences, NotificationValidationError> {
368    validate_notification_preferences_write(request)?;
369    let mut next = request
370        .preferences
371        .clone()
372        .ok_or(NotificationValidationError::InvalidRule)?;
373    if next.rules.iter().any(|rule| {
374        rule.spool
375            .as_ref()
376            .is_some_and(|spool| !readable.contains(spool))
377    }) || next.digest_overrides.iter().any(|item| {
378        item.spool
379            .as_ref()
380            .is_none_or(|spool| !readable.contains(spool))
381    }) {
382        return Err(NotificationValidationError::InvalidSource);
383    }
384    if !request.clear_unreadable_scopes {
385        next.rules.extend(
386            stored
387                .rules
388                .iter()
389                .filter(|rule| {
390                    rule.spool
391                        .as_ref()
392                        .is_some_and(|spool| !readable.contains(spool))
393                })
394                .cloned(),
395        );
396        next.digest_overrides.extend(
397            stored
398                .digest_overrides
399                .iter()
400                .filter(|item| {
401                    item.spool
402                        .as_ref()
403                        .is_some_and(|spool| !readable.contains(spool))
404                })
405                .cloned(),
406        );
407    }
408    next.effective_delivery.clear();
409    next.next_digest_at = None;
410    for item in &mut next.digest_overrides {
411        item.next_digest_at = None;
412    }
413    Ok(next)
414}
415
416/// Invitation-only routing: recipient binding authorizes delivery even without
417/// spool read. In particular Decline to a departed inviter MUST use account
418/// scope, never InvalidSource and never weaken the ordinary provenance gate.
419pub fn resolve_invitation_notification_delivery(
420    rules: &[NotificationRule],
421    cell: &EffectiveDelivery,
422    ancestors: &[NotificationAncestor],
423    ancestor_limit: usize,
424    default_delivery: Delivery,
425    digest_enabled: bool,
426) -> Result<EffectiveDelivery, NotificationValidationError> {
427    if !matches!(
428        cell.kind.as_str(),
429        "spool_invitation" | "spool_invitation_declined"
430    ) {
431        return Err(NotificationValidationError::InvalidRule);
432    }
433    if cell.spool.is_some() && ancestors.first().is_some_and(|level| !level.readable) {
434        let account = EffectiveDelivery {
435            spool: None,
436            ..cell.clone()
437        };
438        return resolve_notification_delivery(
439            rules,
440            &account,
441            &[],
442            ancestor_limit,
443            default_delivery,
444            digest_enabled,
445        );
446    }
447    resolve_notification_delivery(
448        rules,
449        cell,
450        ancestors,
451        ancestor_limit,
452        default_delivery,
453        digest_enabled,
454    )
455}