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}