Skip to main content

ehds_logging/
retention.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! How long an access record is kept (Regulation (EU) 2025/327 Art 9(2),
5//! Annex II 3.4).
6//!
7//! Art 9(2) keeps the information on each access "available for at least
8//! three years from each date of access", and Annex II 3.4 asks for
9//! "different retention periods ... that take into account the origins and
10//! categories" of the data. A [`RetentionPolicy`] declares a [`Period`] for
11//! every record, per category and per origin, within [`Bounds`]: inside the
12//! Union never under [`FLOOR_YEARS`], over a national floor and under a
13//! ceiling when the deployment names them, and outside the Union over the
14//! floor its own law sets, which every record then names as its ground in
15//! place of Art 9(2). A record is kept for the longest period its categories
16//! and origins call for, and an access the map could not classify for the
17//! longest the policy declares anywhere, so no record is kept for less than
18//! one of its parts needs (no specification governs how origin and category
19//! combine: our own design).
20//!
21//! The crate carries no table of national law: the deployment knows where it
22//! runs and which law binds it, and states each bound with its legal ground.
23//!
24//! The store that holds the records applies the period: the record states it
25//! as a [`Retention`], with the first date it may be deleted, or none for a
26//! record kept without end.
27
28use std::collections::BTreeMap;
29use std::fmt;
30
31use jiff::civil::Date;
32use jiff::tz::Offset;
33use jiff::{Span, Timestamp};
34
35use crate::category::Category;
36use crate::classify::Classification;
37use crate::map::CategoryMap;
38
39/// The fewest years a record is kept inside the Union: "at least three years
40/// from each date of access" (Art 9(2)).
41pub const FLOOR_YEARS: u16 = 3;
42
43/// A retention period in whole years, never under [`FLOOR_YEARS`].
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
45pub struct Years(u16);
46
47impl Years {
48    /// The floor of Art 9(2), three years.
49    pub const FLOOR: Self = Self(FLOOR_YEARS);
50
51    /// The period of `years`, when it is not under the floor of Art 9(2).
52    #[must_use]
53    pub const fn new(years: u16) -> Option<Self> {
54        if years < FLOOR_YEARS {
55            None
56        } else {
57            Some(Self(years))
58        }
59    }
60
61    /// The number of years.
62    #[must_use]
63    pub const fn get(self) -> u16 {
64        self.0
65    }
66}
67
68/// How long a record is kept from the date of access.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70#[non_exhaustive]
71pub enum Period {
72    /// Whole years.
73    Years(u16),
74    /// Days.
75    Days(u32),
76    /// Without end: Art 9(2) sets a floor, never a ceiling, so a record may
77    /// be kept for as long as the deployment keeps it.
78    Indefinite,
79}
80
81impl Period {
82    /// The years of a period in years that stands over the floor of Art
83    /// 9(2).
84    #[must_use]
85    pub fn years(self) -> Option<Years> {
86        match self {
87            Self::Years(years) => Years::new(years),
88            Self::Days(_) | Self::Indefinite => None,
89        }
90    }
91
92    /// The period as a record writes it: an ISO 8601 duration (`P3Y`,
93    /// `P1100D`), or `indefinite`.
94    #[must_use]
95    pub fn code(self) -> String {
96        match self {
97            Self::Years(years) => format!("P{years}Y"),
98            Self::Days(days) => format!("P{days}D"),
99            Self::Indefinite => "indefinite".to_owned(),
100        }
101    }
102
103    /// The fewest days the period spans from any date of access; `None`
104    /// without end.
105    fn fewest_days(self) -> Option<u64> {
106        match self {
107            Self::Years(years) => Some(u64::from(years).saturating_mul(365)),
108            Self::Days(days) => Some(u64::from(days)),
109            Self::Indefinite => None,
110        }
111    }
112
113    /// The most days the period can span from a date of access: any run of
114    /// years holds at most one leap day in four, rounded up; `None` without
115    /// end.
116    fn most_days(self) -> Option<u64> {
117        match self {
118            Self::Years(years) => {
119                let years = u64::from(years);
120                Some(years.saturating_mul(365).saturating_add(years.div_ceil(4)))
121            }
122            Self::Days(days) => Some(u64::from(days)),
123            Self::Indefinite => None,
124        }
125    }
126
127    /// Whether this period reaches `floor` from every date of access.
128    fn reaches(self, floor: Self) -> bool {
129        if let (Self::Years(kept), Self::Years(needed)) = (self, floor) {
130            return kept >= needed;
131        }
132        match (self.fewest_days(), floor.most_days()) {
133            (None, _) => true,
134            (Some(_), None) => false,
135            (Some(kept), Some(needed)) => kept >= needed,
136        }
137    }
138
139    /// Whether this period stays within `ceiling` from every date of access.
140    fn within(self, ceiling: Self) -> bool {
141        if let (Self::Years(kept), Self::Years(limit)) = (self, ceiling) {
142            return kept <= limit;
143        }
144        match (self.most_days(), ceiling.fewest_days()) {
145            (_, None) => true,
146            (None, Some(_)) => false,
147            (Some(kept), Some(limit)) => kept <= limit,
148        }
149    }
150
151    /// The first UTC date a record of an access on `accessed` kept for this
152    /// period may be deleted on: the date of access plus the period, plus
153    /// one day; `None` for a period without end.
154    fn ends(self, accessed: Date) -> Option<Date> {
155        let span = match self {
156            Self::Years(years) => Span::new()
157                .try_years(i64::from(years))
158                .and_then(|span| span.try_days(1)),
159            Self::Days(days) => Span::new().try_days(i64::from(days).saturating_add(1)),
160            Self::Indefinite => return None,
161        };
162        // NOTE: Art 9(2) sets a floor, so a date past the calendar's end keeps the record longer,
163        // never shorter; the added day keeps a leap day's access its full period.
164        Some(span.map_or(Date::MAX, |span| accessed.saturating_add(span)))
165    }
166
167    /// Whether `other` keeps a record of an access on `accessed` longer
168    /// than this period does.
169    fn outlasted_by(self, other: Self, accessed: Date) -> bool {
170        match (self.ends(accessed), other.ends(accessed)) {
171            (None, _) => false,
172            (Some(_), None) => true,
173            (Some(ours), Some(theirs)) => theirs > ours,
174        }
175    }
176}
177
178impl fmt::Display for Period {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.write_str(&self.code())
181    }
182}
183
184/// On what legal ground a record's floor stands.
185#[derive(Debug, Clone, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum FloorGround {
188    /// Art 9(2) of Regulation (EU) 2025/327: three years from each date of
189    /// access.
190    Union,
191    /// A national law of a Member State that keeps records longer than Art
192    /// 9(2), as the deployment cites it.
193    National(String),
194    /// The law of a deployment outside the Union, as the deployment cites
195    /// it, which Art 9(2) does not bind; its floor may be under three years.
196    OutsideUnion(String),
197}
198
199impl FloorGround {
200    /// The ground as a record writes it: `eu-2025-327-art-9-2`,
201    /// `national:<citation>` or `outside-union:<citation>`.
202    #[must_use]
203    pub fn code(&self) -> String {
204        match self {
205            Self::Union => "eu-2025-327-art-9-2".to_owned(),
206            Self::National(citation) => format!("national:{citation}"),
207            Self::OutsideUnion(citation) => format!("outside-union:{citation}"),
208        }
209    }
210}
211
212/// The bounds a retention policy is declared within: a floor with its legal
213/// ground, and a ceiling with its legal ground when a law sets one.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct Bounds {
216    floor: Period,
217    floor_ground: FloorGround,
218    ceiling: Option<(Period, String)>,
219}
220
221impl Default for Bounds {
222    fn default() -> Self {
223        Self::union()
224    }
225}
226
227impl Bounds {
228    /// The bounds of a deployment inside the Union with no national law of
229    /// its own: the three years of Art 9(2), and no ceiling.
230    #[must_use]
231    pub fn union() -> Self {
232        Self {
233            floor: Period::Years(FLOOR_YEARS),
234            floor_ground: FloorGround::Union,
235            ceiling: None,
236        }
237    }
238
239    /// The bounds of a deployment inside the Union whose national law keeps
240    /// records at least `floor`, cited as `ground`.
241    ///
242    /// # Errors
243    ///
244    /// [`RetentionError::NationalUnderUnion`] for a floor that does not
245    /// reach the three years of Art 9(2) from every date of access, and
246    /// [`RetentionError::FloorIndefinite`] for a floor without end.
247    pub fn national(floor: Period, ground: String) -> Result<Self, RetentionError> {
248        if floor == Period::Indefinite {
249            return Err(RetentionError::FloorIndefinite);
250        }
251        if !floor.reaches(Period::Years(FLOOR_YEARS)) {
252            return Err(RetentionError::NationalUnderUnion { floor });
253        }
254        Ok(Self {
255            floor,
256            floor_ground: FloorGround::National(ground),
257            ceiling: None,
258        })
259    }
260
261    /// The bounds of a deployment outside the Union, which keeps records at
262    /// least `floor` under its own law, cited as `ground`; the floor may be
263    /// under three years, and every record names `ground`, never Art 9(2).
264    ///
265    /// # Errors
266    ///
267    /// [`RetentionError::FloorIndefinite`] for a floor without end.
268    pub fn outside_union(floor: Period, ground: String) -> Result<Self, RetentionError> {
269        if floor == Period::Indefinite {
270            return Err(RetentionError::FloorIndefinite);
271        }
272        Ok(Self {
273            floor,
274            floor_ground: FloorGround::OutsideUnion(ground),
275            ceiling: None,
276        })
277    }
278
279    /// These bounds, under a law that keeps records at most `ceiling`, cited
280    /// as `ground`.
281    ///
282    /// # Errors
283    ///
284    /// [`RetentionError::CeilingUnderFloor`] for a ceiling that does not
285    /// reach the floor from every date of access.
286    pub fn with_ceiling(mut self, ceiling: Period, ground: String) -> Result<Self, RetentionError> {
287        if !ceiling.reaches(self.floor) {
288            return Err(RetentionError::CeilingUnderFloor {
289                ceiling,
290                floor: self.floor,
291            });
292        }
293        self.ceiling = Some((ceiling, ground));
294        Ok(self)
295    }
296
297    /// The floor.
298    #[must_use]
299    pub fn floor(&self) -> Period {
300        self.floor
301    }
302
303    /// The legal ground of the floor.
304    #[must_use]
305    pub fn floor_ground(&self) -> &FloorGround {
306        &self.floor_ground
307    }
308
309    /// The ceiling and its legal ground, when a law sets one.
310    #[must_use]
311    pub fn ceiling(&self) -> Option<(Period, &str)> {
312        self.ceiling
313            .as_ref()
314            .map(|(period, ground)| (*period, ground.as_str()))
315    }
316
317    /// `period`, declared at `key`, when it stands within these bounds.
318    fn admit(&self, key: String, period: Period) -> Result<Period, RetentionError> {
319        if !period.reaches(self.floor) {
320            if let (Period::Years(years), FloorGround::Union) = (period, &self.floor_ground) {
321                return Err(RetentionError::UnderFloor { key, years });
322            }
323            return Err(RetentionError::UnderBound {
324                key,
325                period,
326                floor: self.floor,
327                ground: self.floor_ground.code(),
328            });
329        }
330        if let Some((ceiling, ground)) = &self.ceiling
331            && !period.within(*ceiling)
332        {
333            return Err(RetentionError::OverCeiling {
334                key,
335                period,
336                ceiling: *ceiling,
337                ground: ground.clone(),
338            });
339        }
340        Ok(period)
341    }
342}
343
344/// What called for a record's period.
345#[derive(Debug, Clone, PartialEq, Eq)]
346#[non_exhaustive]
347pub enum Ground {
348    /// The period every record is kept for.
349    Default,
350    /// The access is unclassified, so it is kept for the longest period
351    /// declared anywhere.
352    Unclassified,
353    /// One of the categories of the data accessed.
354    Category(Category),
355    /// One of the origins of the data, by its key: its endpoint, or the
356    /// `system_id` of an origin that names no endpoint.
357    Origin(String),
358}
359
360impl Ground {
361    /// The ground as a record writes it: `default`, `unclassified`,
362    /// `category:<system>|<code>` or `origin:<key>`.
363    #[must_use]
364    pub fn code(&self) -> String {
365        match self {
366            Self::Default => "default".to_owned(),
367            Self::Unclassified => "unclassified".to_owned(),
368            Self::Category(category) => format!("category:{}", category.token()),
369            Self::Origin(key) => format!("origin:{key}"),
370        }
371    }
372}
373
374/// How long one record is kept.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct Retention {
377    period: Period,
378    ground: Ground,
379    floor: FloorGround,
380    ends: Option<Date>,
381}
382
383impl Retention {
384    /// The period.
385    #[must_use]
386    pub fn period(&self) -> Period {
387        self.period
388    }
389
390    /// What called for it.
391    #[must_use]
392    pub fn ground(&self) -> &Ground {
393        &self.ground
394    }
395
396    /// The legal ground of the floor the period was declared over.
397    #[must_use]
398    pub fn floor(&self) -> &FloorGround {
399        &self.floor
400    }
401
402    /// The first date, in UTC, on which the record may be deleted: the date
403    /// of access plus the period, plus one day; `None` for a record kept
404    /// without end.
405    #[must_use]
406    pub fn ends(&self) -> Option<Date> {
407        self.ends
408    }
409}
410
411/// Why a retention policy or its bounds were refused.
412#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
413#[non_exhaustive]
414pub enum RetentionError {
415    /// A period in years is under the floor of Art 9(2).
416    #[error(
417        "{key} keeps a record {years} years, under the {FLOOR_YEARS} years from each date of access Regulation (EU) 2025/327 Art 9(2) asks for"
418    )]
419    UnderFloor {
420        /// The key that declares it: `years`, `categories.<code>` or
421        /// `origins.<key>`.
422        key: String,
423        /// The period declared.
424        years: u16,
425    },
426    /// A period does not reach the floor of the bounds from every date of
427    /// access.
428    #[error(
429        "{key} keeps a record {period}, which does not reach the floor of {floor} ({ground}) from every date of access"
430    )]
431    UnderBound {
432        /// The key that declares it.
433        key: String,
434        /// The period declared.
435        period: Period,
436        /// The floor.
437        floor: Period,
438        /// The floor's legal ground, as a record writes it.
439        ground: String,
440    },
441    /// A period passes the ceiling of the bounds.
442    #[error("{key} keeps a record {period}, past the ceiling of {ceiling} ({ground})")]
443    OverCeiling {
444        /// The key that declares it.
445        key: String,
446        /// The period declared.
447        period: Period,
448        /// The ceiling.
449        ceiling: Period,
450        /// The ceiling's legal ground.
451        ground: String,
452    },
453    /// A national floor does not reach the three years of Art 9(2).
454    #[error(
455        "a national floor of {floor} does not reach the {FLOOR_YEARS} years of Regulation (EU) 2025/327 Art 9(2); a deployment outside the Union declares its floor as such"
456    )]
457    NationalUnderUnion {
458        /// The floor declared.
459        floor: Period,
460    },
461    /// A floor is without end.
462    #[error("a retention floor is a finite period")]
463    FloorIndefinite,
464    /// A ceiling does not reach the floor.
465    #[error("the ceiling of {ceiling} does not reach the floor of {floor}")]
466    CeilingUnderFloor {
467        /// The ceiling declared.
468        ceiling: Period,
469        /// The floor.
470        floor: Period,
471    },
472    /// A category key is neither a priority category nor a national one
473    /// the category map declares.
474    #[error("categories key {code:?} is neither a priority category nor a declared national one")]
475    UnknownCategory {
476        /// The code.
477        code: String,
478    },
479    /// An origin key is empty or holds a control character.
480    #[error("an origins key is empty or holds a control character")]
481    OriginKey,
482}
483
484/// The periods a deployment keeps its records for.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct RetentionPolicy {
487    bounds: Bounds,
488    default: Period,
489    categories: BTreeMap<Category, Period>,
490    origins: BTreeMap<String, Period>,
491}
492
493impl Default for RetentionPolicy {
494    fn default() -> Self {
495        Self {
496            bounds: Bounds::union(),
497            default: Period::Years(FLOOR_YEARS),
498            categories: BTreeMap::new(),
499            origins: BTreeMap::new(),
500        }
501    }
502}
503
504impl RetentionPolicy {
505    /// The policy, inside the Union, that keeps every record `years`, the
506    /// data of each category of `categories` and from each origin of
507    /// `origins` as long as they say, in whole years, each category read as
508    /// [`CategoryMap::category`] reads it.
509    ///
510    /// # Errors
511    ///
512    /// A [`RetentionError`] for a period under [`FLOOR_YEARS`], a code that
513    /// names no category, and an empty or malformed origin key.
514    pub fn declare(
515        years: u16,
516        categories: &BTreeMap<String, u16>,
517        origins: &BTreeMap<String, u16>,
518        map: &CategoryMap,
519    ) -> Result<Self, RetentionError> {
520        let in_years = |table: &BTreeMap<String, u16>| {
521            table
522                .iter()
523                .map(|(key, years)| (key.clone(), Period::Years(*years)))
524                .collect::<BTreeMap<_, _>>()
525        };
526        Self::declare_within(
527            &Bounds::union(),
528            Period::Years(years),
529            &in_years(categories),
530            &in_years(origins),
531            map,
532        )
533    }
534
535    /// The policy within `bounds` that keeps every record `default`, the
536    /// data of each category of `categories` and from each origin of
537    /// `origins` (by its key: its endpoint, or the `system_id` of an origin
538    /// that names no endpoint) as long as they say.
539    ///
540    /// # Errors
541    ///
542    /// A [`RetentionError`] for a period outside the bounds, a code that
543    /// names no category, and an empty or malformed origin key.
544    pub fn declare_within(
545        bounds: &Bounds,
546        default: Period,
547        categories: &BTreeMap<String, Period>,
548        origins: &BTreeMap<String, Period>,
549        map: &CategoryMap,
550    ) -> Result<Self, RetentionError> {
551        let default = bounds.admit("years".to_owned(), default)?;
552        let categories = categories
553            .iter()
554            .map(|(code, period)| {
555                let category = map
556                    .category(code)
557                    .ok_or_else(|| RetentionError::UnknownCategory { code: code.clone() })?;
558                Ok((
559                    category,
560                    bounds.admit(format!("categories.{code}"), *period)?,
561                ))
562            })
563            .collect::<Result<BTreeMap<_, _>, RetentionError>>()?;
564        let origins = origins
565            .iter()
566            .map(|(key, period)| {
567                if key.is_empty() || key.chars().any(char::is_control) {
568                    return Err(RetentionError::OriginKey);
569                }
570                Ok((
571                    key.clone(),
572                    bounds.admit(format!("origins.{key}"), *period)?,
573                ))
574            })
575            .collect::<Result<BTreeMap<_, _>, RetentionError>>()?;
576        Ok(Self {
577            bounds: bounds.clone(),
578            default,
579            categories,
580            origins,
581        })
582    }
583
584    /// The bounds the policy was declared within.
585    #[must_use]
586    pub fn bounds(&self) -> &Bounds {
587        &self.bounds
588    }
589
590    /// The origin keys the policy names.
591    pub fn origins(&self) -> impl Iterator<Item = &str> {
592        self.origins.keys().map(String::as_str)
593    }
594
595    /// The longest period the policy declares anywhere, by the fewest days
596    /// each spans; [`RetentionPolicy::retain`] compares by the end date each
597    /// gives a record instead.
598    #[must_use]
599    pub fn longest(&self) -> Period {
600        self.categories
601            .values()
602            .chain(self.origins.values())
603            .copied()
604            .fold(self.default, |kept, candidate| {
605                let longer = match (kept.fewest_days(), candidate.fewest_days()) {
606                    (None, _) => false,
607                    (Some(_), None) => true,
608                    (Some(ours), Some(theirs)) => theirs > ours,
609                };
610                if longer { candidate } else { kept }
611            })
612    }
613
614    /// How long the record of an access made at `recorded`, classified as
615    /// `classified`, from the origins keyed `origins`, is kept.
616    ///
617    /// The period is the longest of the default, each category's and each
618    /// origin's, and the longest declared anywhere for an access that is
619    /// unclassified in whole or in part, each compared by the end date it
620    /// gives this record. The ground is the first of these, in that order,
621    /// that calls for the period.
622    #[must_use]
623    pub fn retain<'a>(
624        &self,
625        recorded: Timestamp,
626        classified: &Classification,
627        origins: impl IntoIterator<Item = &'a str>,
628    ) -> Retention {
629        let accessed = Offset::UTC.to_datetime(recorded).date();
630        let longest = |candidates: Vec<(Period, Ground)>| {
631            candidates
632                .into_iter()
633                .fold((self.default, Ground::Default), |kept, candidate| {
634                    if kept.0.outlasted_by(candidate.0, accessed) {
635                        candidate
636                    } else {
637                        kept
638                    }
639                })
640        };
641        let (period, ground) = if classified.unclassified().is_some() {
642            let (period, _) = longest(
643                self.categories
644                    .values()
645                    .chain(self.origins.values())
646                    .map(|period| (*period, Ground::Unclassified))
647                    .collect(),
648            );
649            (period, Ground::Unclassified)
650        } else {
651            let categories = classified.categories().keys().filter_map(|category| {
652                self.categories
653                    .get(category)
654                    .map(|period| (*period, Ground::Category(category.clone())))
655            });
656            let origins = origins.into_iter().filter_map(|key| {
657                self.origins
658                    .get(key)
659                    .map(|period| (*period, Ground::Origin(key.to_owned())))
660            });
661            longest(categories.chain(origins).collect())
662        };
663        Retention {
664            period,
665            ground,
666            floor: self.bounds.floor_ground.clone(),
667            ends: period.ends(accessed),
668        }
669    }
670}