Skip to main content

ehds_logging/
withheld.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The records a person does not see yet: access records about a resource
5//! held from the person under Regulation (EU) 2025/327 Art 3(3).
6//!
7//! Art 3(3) lets a Member State delay a person's access to their data "for a
8//! limited period of time until a health professional is able to properly
9//! communicate and explain" it, and Art 9(1) and (2) give the person the
10//! record of every access to their data: who, when and "which personal
11//! electronic health data were accessed". While a result is held, its access
12//! records in the person's own trail would tell them it exists. So the
13//! product that holds a resource names the hold here, and the person-facing
14//! read leaves out the records about that resource until the hold is released
15//! or expires; then they appear, unchanged, with their original times.
16//! Professional and supervisory reads see every record at all times.
17//!
18//! A [`Holds`] register is keyed by the resource as the records name it
19//! ([`Request::resource`](crate::record::Request::resource), a written
20//! version, a version the access reached): the product sets a hold with
21//! [`Holds::hold`] and lifts it with [`Holds::release`], and the read asks
22//! [`Holds::shows`] or filters with [`Holds::trail`]. No record is altered,
23//! so the trail keeps its integrity and its Art 9(2) retention.
24//!
25//! # Examples
26//!
27//! ```
28//! use ehds_logging::withheld::{Holds, Reader};
29//! use jiff::{SignedDuration, Timestamp};
30//!
31//! let registered: Timestamp = "2026-10-31T09:00:00Z".parse()?;
32//! let mut holds = Holds::default();
33//! holds.hold("8fa1c0de", registered + SignedDuration::from_hours(72));
34//!
35//! // A record about version 1 of the held object.
36//! assert!(holds.withholds("8fa1c0de::cdr.example.org::1", registered));
37//! assert_eq!(Reader::Person.is_person(), true);
38//!
39//! // The professional explains the result and releases it.
40//! holds.release("8fa1c0de", registered + SignedDuration::from_hours(2))?;
41//! assert!(!holds.withholds("8fa1c0de", registered + SignedDuration::from_hours(2)));
42//! # Ok::<(), Box<dyn std::error::Error>>(())
43//! ```
44
45use std::collections::BTreeMap;
46
47use jiff::Timestamp;
48
49use crate::record::AccessRecord;
50
51/// One hold on a resource: until when it is withheld from the person, and
52/// when a professional released it, if they did.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Hold {
55    until: Timestamp,
56    released: Option<Timestamp>,
57}
58
59impl Hold {
60    /// A hold that withholds the resource from the person until `until`.
61    ///
62    /// Every hold ends by itself, since Art 3(3) admits a delay "for a
63    /// limited period of time" only.
64    #[must_use]
65    pub const fn new(until: Timestamp) -> Self {
66        Self {
67            until,
68            released: None,
69        }
70    }
71
72    /// When the hold expires unless released earlier.
73    #[must_use]
74    pub const fn expires(&self) -> Timestamp {
75        self.until
76    }
77
78    /// When a professional released the hold, if they did.
79    #[must_use]
80    pub const fn released(&self) -> Option<Timestamp> {
81        self.released
82    }
83
84    /// When the person sees the resource's records: the release, or the
85    /// expiry, whichever comes first.
86    #[must_use]
87    pub fn ends(&self) -> Timestamp {
88        self.released
89            .map_or(self.until, |released| released.min(self.until))
90    }
91
92    /// Whether the hold withholds the resource from the person at `now`.
93    #[must_use]
94    pub fn withholds(&self, now: Timestamp) -> bool {
95        now < self.ends()
96    }
97
98    /// Releases the hold at `at`; a hold released twice keeps its earlier
99    /// release.
100    pub fn release(&mut self, at: Timestamp) {
101        self.released = Some(self.released.map_or(at, |released| released.min(at)));
102    }
103}
104
105/// Who reads the access trail.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum Reader {
109    /// The data subject, or a representative acting for them (Art 4(2)),
110    /// reading their own trail under Art 9.
111    Person,
112    /// A health professional or the controller's staff.
113    Professional,
114    /// A supervisory authority, or the controller reviewing its log (Annex
115    /// II 3.3).
116    Supervisory,
117}
118
119impl Reader {
120    /// Whether the reader is the person a hold withholds from.
121    #[must_use]
122    pub const fn is_person(self) -> bool {
123        matches!(self, Self::Person)
124    }
125}
126
127/// Why a hold could not be lifted.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
129#[non_exhaustive]
130pub enum HoldError {
131    /// The register holds no hold on the resource.
132    #[error("the resource is not held from the person")]
133    NotHeld,
134}
135
136/// The holds a product keeps on resources, keyed by the resource as its
137/// access records name it.
138///
139/// A product passes, for one person's read, a register that holds that
140/// person's holds; the register is a value it builds from its own store.
141#[derive(Debug, Clone, Default, PartialEq, Eq)]
142pub struct Holds {
143    by_resource: BTreeMap<String, Hold>,
144}
145
146impl Holds {
147    /// Holds `resource` from the person until `until`.
148    ///
149    /// A hold in force on the resource is extended to the later of the two
150    /// ends; a hold already released is replaced, so a professional can hold
151    /// an amended resource again.
152    pub fn hold(&mut self, resource: impl Into<String>, until: Timestamp) {
153        let fresh = Hold::new(until);
154        self.by_resource
155            .entry(resource.into())
156            .and_modify(|hold| {
157                *hold = match hold.released {
158                    None => Hold::new(hold.until.max(until)),
159                    Some(_) => fresh,
160                };
161            })
162            .or_insert(fresh);
163    }
164
165    /// Releases the hold on `resource` at `at`: from then on the person sees
166    /// its records, with their original times.
167    ///
168    /// # Errors
169    ///
170    /// [`HoldError::NotHeld`] when the register holds no hold on `resource`.
171    pub fn release(&mut self, resource: &str, at: Timestamp) -> Result<(), HoldError> {
172        let hold = self
173            .by_resource
174            .get_mut(resource)
175            .ok_or(HoldError::NotHeld)?;
176        hold.release(at);
177        Ok(())
178    }
179
180    /// The hold on `resource`, if the register holds one.
181    #[must_use]
182    pub fn get(&self, resource: &str) -> Option<&Hold> {
183        self.by_resource.get(resource)
184    }
185
186    /// Whether `resource` is withheld from the person at `now`.
187    ///
188    /// An openEHR `OBJECT_VERSION_ID` is withheld when its `object_id` is, so
189    /// a hold on a versioned object covers each of its versions.
190    #[must_use]
191    pub fn withholds(&self, resource: &str, now: Timestamp) -> bool {
192        // NOTE: openEHR BASE 1.3.0 identification package, an OBJECT_VERSION_ID is
193        // `object_id::creating_system_id::version_tree_id`, every version sharing the object_id.
194        let object = resource.split_once("::").map(|(object, _)| object);
195        [Some(resource), object]
196            .into_iter()
197            .flatten()
198            .filter_map(|id| self.by_resource.get(id))
199            .any(|hold| hold.withholds(now))
200    }
201
202    /// Whether `reader` sees `record` in the access trail at `now`.
203    ///
204    /// A professional or supervisory reader sees every record. The person
205    /// does not see a record that names a resource withheld at `now`, by its
206    /// request, a version a write left or a version the access reached. A
207    /// record whose versions the map's evidence limit cut cannot be cleared,
208    /// so the person does not see it while any hold of the register is in
209    /// force.
210    #[must_use]
211    pub fn shows(&self, reader: Reader, record: &AccessRecord, now: Timestamp) -> bool {
212        // NOTE: Art 9(1) gives the person the record of every access, and Art 3(3) delays
213        // their access to held data; the delay reaches only the person's own read.
214        if !reader.is_person() || self.by_resource.is_empty() {
215            return true;
216        }
217        let cut = record.categories.evidence_counts().versions > record.categories.versions().len()
218            || record.origins.iter().any(|origin| {
219                origin.categories.as_ref().is_some_and(|categories| {
220                    categories.evidence_counts().versions > categories.versions().len()
221                })
222            });
223        if cut && self.by_resource.values().any(|hold| hold.withholds(now)) {
224            return false;
225        }
226        !named(record).any(|resource| self.withholds(resource, now))
227    }
228
229    /// The records of `records` that `reader` sees at `now`, in their order
230    /// and unchanged.
231    pub fn trail<'a>(
232        &'a self,
233        reader: Reader,
234        records: impl IntoIterator<Item = &'a AccessRecord> + 'a,
235        now: Timestamp,
236    ) -> impl Iterator<Item = &'a AccessRecord> + 'a {
237        records
238            .into_iter()
239            .filter(move |record| self.shows(reader, record, now))
240    }
241}
242
243/// Every resource `record` names: the one its request addressed, each
244/// version a write left, and each version the access reached.
245fn named(record: &AccessRecord) -> impl Iterator<Item = &str> {
246    let origins = record.origins.iter().flat_map(|origin| {
247        let versions = origin
248            .categories
249            .iter()
250            .flat_map(|categories| categories.versions().iter().map(String::as_str));
251        origin
252            .version
253            .as_ref()
254            .and_then(|version| version.named())
255            .into_iter()
256            .chain(versions)
257    });
258    record
259        .request
260        .resource
261        .as_deref()
262        .into_iter()
263        .chain(record.categories.versions().iter().map(String::as_str))
264        .chain(origins)
265}