use sqlx::PgPool;
use time::OffsetDateTime;
use tracing::{info, warn};
use zeroize::Zeroizing;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SubjectRef(String);
const EPOCH_PREFIX: char = 's';
impl SubjectRef {
pub fn new(reference: impl Into<String>) -> Result<Self> {
let reference = reference.into();
if reference.trim().is_empty() {
return Err(Error::config("subject reference must not be empty"));
}
let this = Self(reference);
this.epoch()?;
Ok(this)
}
fn mint(epoch: i32) -> Self {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
Self(format!("{EPOCH_PREFIX}{epoch}_{}", hex(&bytes)))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn epoch(&self) -> Result<i32> {
let malformed = || {
Error::config(format!(
"subject reference {:?} does not name a retention epoch: the shape is \
`s<year>_<token>`, which is what lets a write check a reference against \
the year of the reading it is attached to. Mint one with \
`SubjectRegistry::register`",
self.0
))
};
let rest = self.0.strip_prefix(EPOCH_PREFIX).ok_or_else(malformed)?;
let (year, token) = rest.split_once('_').ok_or_else(malformed)?;
if token.is_empty() {
return Err(malformed());
}
year.parse::<i32>().map_err(|_| malformed())
}
}
impl std::fmt::Display for SubjectRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErasureRecord {
pub subject: SubjectRef,
pub erased_at: OffsetDateTime,
pub reason: String,
pub actor: String,
}
#[derive(Clone)]
pub struct SubjectRegistry {
pool: PgPool,
erasure_secret: Option<Zeroizing<Vec<u8>>>,
}
impl std::fmt::Debug for SubjectRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubjectRegistry")
.field("suppression", &self.erasure_secret.is_some())
.finish_non_exhaustive()
}
}
pub const MIN_ERASURE_SECRET_BYTES: usize = 32;
impl SubjectRegistry {
pub fn new(pool: PgPool) -> Self {
Self {
pool,
erasure_secret: None,
}
}
pub fn with_erasure_secret(pool: PgPool, secret: &[u8]) -> Result<Self> {
if secret.len() < MIN_ERASURE_SECRET_BYTES {
return Err(Error::config(format!(
"erasure secret must be at least {MIN_ERASURE_SECRET_BYTES} bytes: a \
shorter key can be brute-forced, and the suppression list would then \
leak the identifiers it exists to forget"
)));
}
Ok(Self {
pool,
erasure_secret: Some(Zeroizing::new(secret.to_vec())),
})
}
pub fn suppresses_reregistration(&self) -> bool {
self.erasure_secret.is_some()
}
fn tombstone(&self, natural_id: &str) -> Option<Vec<u8>> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let secret = self.erasure_secret.as_ref()?;
let mut mac =
<Hmac<Sha256>>::new_from_slice(secret).expect("HMAC accepts keys of any length");
mac.update(natural_id.as_bytes());
Some(mac.finalize().into_bytes().to_vec())
}
pub async fn create_tables(&self) -> Result<()> {
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS meterstore_subject_map (
subject_ref TEXT PRIMARY KEY,
natural_id TEXT NOT NULL,
-- The calendar year of the values this row may attribute.
-- `(natural_id, epoch)` rather than `natural_id` alone,
-- because § 60 Abs. 6 runs per value: a subject's 2020
-- readings come due while their 2026 readings are current,
-- and one row covering both could satisfy neither.
epoch INTEGER NOT NULL,
registered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (natural_id, epoch)
)"#,
)
.execute(&self.pool)
.await
.map_err(pg)?;
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS meterstore_erasures (
subject_ref TEXT PRIMARY KEY,
erased_at TIMESTAMPTZ NOT NULL,
reason TEXT NOT NULL,
actor TEXT NOT NULL,
-- Keyed hash of the erased identifier. NULL when the
-- deployment configured no suppression key, in which case a
-- replayed message can re-register the subject.
natural_id_hmac BYTEA
)"#,
)
.execute(&self.pool)
.await
.map_err(pg)?;
sqlx::query(
r#"CREATE INDEX IF NOT EXISTS meterstore_subject_map_epoch
ON meterstore_subject_map (epoch)"#,
)
.execute(&self.pool)
.await
.map_err(pg)?;
sqlx::query(
r#"CREATE INDEX IF NOT EXISTS meterstore_erasures_hmac
ON meterstore_erasures (natural_id_hmac)
WHERE natural_id_hmac IS NOT NULL"#,
)
.execute(&self.pool)
.await
.map_err(pg)?;
info!(
suppression = self.suppresses_reregistration(),
"subject registry ready"
);
Ok(())
}
pub async fn register(&self, natural_id: &str, at: OffsetDateTime) -> Result<SubjectRef> {
if natural_id.trim().is_empty() {
return Err(Error::config("natural identifier must not be empty"));
}
let epoch = retention_epoch(at);
if let Some(existing) = self.lookup(natural_id, at).await? {
return Ok(existing);
}
if let Some(tombstone) = self.tombstone(natural_id) {
let suppressed = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM meterstore_erasures WHERE natural_id_hmac = $1)",
)
.bind(&tombstone)
.fetch_one(&self.pool)
.await
.map_err(pg)?;
if suppressed {
warn!("registration refused for an erased identifier");
return Err(Error::config(
"this identifier was erased and must not be re-registered: \
registering it would rebuild the link Article 17 destroyed. \
A subject who genuinely returns should arrive under a new \
identifier; if the erasure itself was mistaken, lift it \
explicitly with `lift_suppression`",
));
}
}
let reference = SubjectRef::mint(epoch);
let inserted = sqlx::query_scalar::<_, String>(
r#"INSERT INTO meterstore_subject_map (subject_ref, natural_id, epoch)
VALUES ($1, $2, $3)
ON CONFLICT (natural_id, epoch) DO UPDATE SET natural_id = EXCLUDED.natural_id
RETURNING subject_ref"#,
)
.bind(reference.as_str())
.bind(natural_id)
.bind(epoch)
.fetch_one(&self.pool)
.await
.map_err(pg)?;
SubjectRef::new(inserted)
}
pub async fn lookup(&self, natural_id: &str, at: OffsetDateTime) -> Result<Option<SubjectRef>> {
let found = sqlx::query_scalar::<_, String>(
"SELECT subject_ref FROM meterstore_subject_map WHERE natural_id = $1 AND epoch = $2",
)
.bind(natural_id)
.bind(retention_epoch(at))
.fetch_optional(&self.pool)
.await
.map_err(pg)?;
found.map(SubjectRef::new).transpose()
}
pub async fn resolve(&self, subject: &SubjectRef) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>(
"SELECT natural_id FROM meterstore_subject_map WHERE subject_ref = $1",
)
.bind(subject.as_str())
.fetch_optional(&self.pool)
.await
.map_err(pg)
}
pub async fn erase(
&self,
subject: &SubjectRef,
reason: &str,
actor: &str,
now: OffsetDateTime,
) -> Result<Vec<ErasureRecord>> {
self.erase_triggered_by(subject, reason, actor, now, crate::observe::TRIGGER_REQUEST)
.await
}
pub(crate) async fn erase_triggered_by(
&self,
subject: &SubjectRef,
reason: &str,
actor: &str,
now: OffsetDateTime,
trigger: &'static str,
) -> Result<Vec<ErasureRecord>> {
let mut tx = self.pool.begin().await.map_err(pg)?;
let (records, destroyed) = self
.erase_in_inner(&mut tx, subject, reason, actor, now)
.await?;
tx.commit().await.map_err(pg)?;
if destroyed {
crate::observe::metrics()
.subjects_erased
.add(1, &crate::observe::erasure_trigger(trigger));
}
Ok(records)
}
pub async fn erase_in(
&self,
conn: &mut sqlx::PgConnection,
subject: &SubjectRef,
reason: &str,
actor: &str,
now: OffsetDateTime,
) -> Result<Vec<ErasureRecord>> {
let (records, destroyed) = self
.erase_in_inner(conn, subject, reason, actor, now)
.await?;
if destroyed {
crate::observe::metrics().subjects_erased.add(
1,
&crate::observe::erasure_trigger(crate::observe::TRIGGER_REQUEST),
);
}
Ok(records)
}
async fn erase_in_inner(
&self,
conn: &mut sqlx::PgConnection,
subject: &SubjectRef,
reason: &str,
actor: &str,
now: OffsetDateTime,
) -> Result<(Vec<ErasureRecord>, bool)> {
if reason.trim().is_empty() {
return Err(Error::config("erasure needs a reason for the audit trail"));
}
let tx = conn;
let natural_id = sqlx::query_scalar::<_, String>(
"SELECT natural_id FROM meterstore_subject_map WHERE subject_ref = $1 FOR UPDATE",
)
.bind(subject.as_str())
.fetch_optional(&mut *tx)
.await
.map_err(pg)?;
let tombstone = natural_id.as_deref().and_then(|id| self.tombstone(id));
let targets: Vec<SubjectRef> = match natural_id.as_deref() {
Some(id) => sqlx::query_scalar::<_, String>(
"SELECT subject_ref FROM meterstore_subject_map WHERE natural_id = $1 \
ORDER BY epoch FOR UPDATE",
)
.bind(id)
.fetch_all(&mut *tx)
.await
.map_err(pg)?
.into_iter()
.map(SubjectRef::new)
.collect::<Result<_>>()?,
None => vec![subject.clone()],
};
let mut deleted = 0;
for target in &targets {
deleted += sqlx::query("DELETE FROM meterstore_subject_map WHERE subject_ref = $1")
.bind(target.as_str())
.execute(&mut *tx)
.await
.map_err(pg)?
.rows_affected();
}
let mut records = Vec::with_capacity(targets.len());
for target in targets {
sqlx::query(
r#"INSERT INTO meterstore_erasures
(subject_ref, erased_at, reason, actor, natural_id_hmac)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (subject_ref) DO UPDATE
SET natural_id_hmac =
COALESCE(meterstore_erasures.natural_id_hmac, EXCLUDED.natural_id_hmac)"#,
)
.bind(target.as_str())
.bind(now)
.bind(reason)
.bind(actor)
.bind(tombstone.as_deref())
.execute(&mut *tx)
.await
.map_err(pg)?;
records.push(ErasureRecord {
subject: target,
erased_at: now,
reason: reason.to_string(),
actor: actor.to_string(),
});
}
if deleted == 0 {
warn!(%subject, "erasure requested for an unmapped reference");
} else {
info!(%subject, epochs = deleted, actor, "subject linkage destroyed");
}
Ok((records, deleted > 0))
}
pub async fn expire_epochs_before(
&self,
cutoff: OffsetDateTime,
reason: &str,
actor: &str,
now: OffsetDateTime,
) -> Result<Vec<ErasureRecord>> {
if reason.trim().is_empty() {
return Err(Error::config("erasure needs a reason for the audit trail"));
}
let due_before = retention_epoch(cutoff);
let mut tx = self.pool.begin().await.map_err(pg)?;
let due = sqlx::query_as::<_, (String, String)>(
"SELECT subject_ref, natural_id FROM meterstore_subject_map \
WHERE epoch < $1 ORDER BY epoch, natural_id FOR UPDATE",
)
.bind(due_before)
.fetch_all(&mut *tx)
.await
.map_err(pg)?;
let mut records = Vec::with_capacity(due.len());
for (reference, natural_id) in due {
let subject = SubjectRef::new(reference)?;
sqlx::query("DELETE FROM meterstore_subject_map WHERE subject_ref = $1")
.bind(subject.as_str())
.execute(&mut *tx)
.await
.map_err(pg)?;
sqlx::query(
r#"INSERT INTO meterstore_erasures
(subject_ref, erased_at, reason, actor, natural_id_hmac)
VALUES ($1, $2, $3, $4, NULL)
ON CONFLICT (subject_ref) DO NOTHING"#,
)
.bind(subject.as_str())
.bind(now)
.bind(reason)
.bind(actor)
.execute(&mut *tx)
.await
.map_err(pg)?;
let _ = natural_id;
records.push(ErasureRecord {
subject,
erased_at: now,
reason: reason.to_string(),
actor: actor.to_string(),
});
}
tx.commit().await.map_err(pg)?;
if !records.is_empty() {
crate::observe::metrics().subjects_erased.add(
records.len() as u64,
&crate::observe::erasure_trigger(crate::observe::TRIGGER_RETENTION),
);
warn!(
epochs = records.len(),
%cutoff,
"retention sweep destroyed linkages past the statutory ceiling"
);
}
Ok(records)
}
pub async fn is_suppressed(&self, natural_id: &str) -> Result<bool> {
let Some(tombstone) = self.tombstone(natural_id) else {
return Ok(false);
};
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM meterstore_erasures WHERE natural_id_hmac = $1)",
)
.bind(&tombstone)
.fetch_one(&self.pool)
.await
.map_err(pg)
}
pub async fn lift_suppression(
&self,
natural_id: &str,
reason: &str,
actor: &str,
) -> Result<bool> {
if reason.trim().is_empty() {
return Err(Error::config(
"lifting a suppression needs a reason: it reverses a compliance \
action and must not be an anonymous edit",
));
}
let Some(tombstone) = self.tombstone(natural_id) else {
return Err(Error::config(
"no suppression key is configured, so there is no suppression to lift",
));
};
let lifted = sqlx::query(
"UPDATE meterstore_erasures SET natural_id_hmac = NULL WHERE natural_id_hmac = $1",
)
.bind(&tombstone)
.execute(&self.pool)
.await
.map_err(pg)?
.rows_affected();
if lifted > 0 {
warn!(actor, reason, "erasure suppression lifted");
}
Ok(lifted > 0)
}
pub async fn is_erased(&self, subject: &SubjectRef) -> Result<bool> {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM meterstore_erasures WHERE subject_ref = $1)",
)
.bind(subject.as_str())
.fetch_one(&self.pool)
.await
.map_err(pg)
}
pub async fn erasures(&self, limit: i64) -> Result<Vec<ErasureRecord>> {
let rows = sqlx::query_as::<_, (String, OffsetDateTime, String, String)>(
r#"SELECT subject_ref, erased_at, reason, actor
FROM meterstore_erasures
ORDER BY erased_at DESC
LIMIT $1"#,
)
.bind(limit)
.fetch_all(&self.pool)
.await
.map_err(pg)?;
rows.into_iter()
.map(|(subject, erased_at, reason, actor)| {
Ok(ErasureRecord {
subject: SubjectRef::new(subject)?,
erased_at,
reason,
actor,
})
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Retention {
CalendarYears(u32),
Rolling(time::Duration),
}
impl Retention {
#[must_use]
pub fn cutoff(self, now: OffsetDateTime) -> OffsetDateTime {
match self {
Self::CalendarYears(years) => {
let year = metering::calendar::local_year(now)
.saturating_sub(i32::try_from(years).unwrap_or(i32::MAX))
.max(EARLIEST_CUTOFF_YEAR);
metering::calendar::year_start_utc(year)
}
Self::Rolling(window) => now
.checked_sub(window)
.unwrap_or_else(|| metering::calendar::year_start_utc(EARLIEST_CUTOFF_YEAR)),
}
}
}
const EARLIEST_CUTOFF_YEAR: i32 = -9998;
#[must_use]
pub fn retention_epoch(at: OffsetDateTime) -> i32 {
metering::calendar::local_year(at)
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes.iter().fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
fn pg(e: sqlx::Error) -> Error {
Error::Storage(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reference_must_not_be_empty() {
assert!(SubjectRef::new("").is_err());
assert!(SubjectRef::new(" ").is_err());
assert!(SubjectRef::new("s2026_abc").is_ok());
}
#[test]
fn a_reference_must_name_its_retention_epoch() {
assert!(SubjectRef::new("sub_abc").is_err());
assert!(SubjectRef::new("s_abc").is_err());
assert!(SubjectRef::new("s2026_").is_err());
assert!(SubjectRef::new("2026_abc").is_err());
assert_eq!(SubjectRef::new("s2026_abc").unwrap().epoch().unwrap(), 2026);
assert_eq!(SubjectRef::new("s-1_abc").unwrap().epoch().unwrap(), -1);
}
#[test]
fn generated_references_do_not_repeat() {
let refs: std::collections::HashSet<_> =
(0..1_000).map(|_| SubjectRef::mint(2026)).collect();
assert_eq!(refs.len(), 1_000, "references must be unique");
}
#[test]
fn a_generated_reference_is_not_derived_from_anything() {
assert_ne!(SubjectRef::mint(2026), SubjectRef::mint(2026));
}
#[test]
fn a_minted_reference_carries_the_epoch_it_was_minted_for() {
assert_eq!(SubjectRef::mint(2024).epoch().unwrap(), 2024);
}
#[test]
fn the_retention_epoch_is_the_berlin_year() {
use time::macros::datetime;
assert_eq!(retention_epoch(datetime!(2026-12-31 22:59 UTC)), 2026);
assert_eq!(retention_epoch(datetime!(2026-12-31 23:00 UTC)), 2027);
}
fn lazy_pool() -> PgPool {
PgPool::connect_lazy("postgresql://unused@localhost/unused").expect("a well-formed URL")
}
#[tokio::test]
async fn the_erasure_key_never_reaches_a_log_line() {
let registry = SubjectRegistry::with_erasure_secret(lazy_pool(), &[0xAB; 32]).unwrap();
let shown = format!("{registry:?}");
assert!(!shown.contains("171"), "{shown}");
assert!(!shown.contains("ab"), "{shown}");
assert!(shown.contains("suppression: true"), "{shown}");
assert!(format!("{:?}", SubjectRegistry::new(lazy_pool())).contains("suppression: false"));
}
#[tokio::test]
async fn a_cloned_registry_keeps_a_key_of_its_own() {
let original = SubjectRegistry::with_erasure_secret(lazy_pool(), &[7; 32]).unwrap();
let derived = original.clone();
let expected = original
.tombstone("41373559241")
.expect("a key is configured");
drop(original);
assert_eq!(derived.tombstone("41373559241"), Some(expected));
}
#[tokio::test]
async fn a_tombstone_is_keyed_rather_than_a_bare_hash() {
let a = SubjectRegistry::with_erasure_secret(lazy_pool(), &[1; 32]).unwrap();
let b = SubjectRegistry::with_erasure_secret(lazy_pool(), &[2; 32]).unwrap();
assert_ne!(a.tombstone("41373559241"), b.tombstone("41373559241"));
assert_eq!(a.tombstone("41373559241"), a.tombstone("41373559241"));
assert!(
SubjectRegistry::new(lazy_pool())
.tombstone("41373559241")
.is_none()
);
}
#[test]
fn the_statutory_ceiling_starts_at_the_end_of_the_collection_year() {
use time::macros::datetime;
let policy = Retention::CalendarYears(3);
assert_eq!(
policy.cutoff(datetime!(2028-01-02 00:00 UTC)),
datetime!(2024-12-31 23:00 UTC)
);
assert_eq!(
policy.cutoff(datetime!(2028-12-31 23:00 UTC)),
datetime!(2025-12-31 23:00 UTC)
);
assert_eq!(
policy.cutoff(datetime!(2028-03-01 00:00 UTC)),
policy.cutoff(datetime!(2028-11-30 00:00 UTC)),
);
}
#[test]
fn a_rolling_window_is_measured_from_the_sweep() {
use time::macros::datetime;
let now = datetime!(2028-06-01 00:00 UTC);
assert_eq!(
Retention::Rolling(time::Duration::days(90)).cutoff(now),
now - time::Duration::days(90)
);
}
#[test]
fn a_period_the_calendar_cannot_express_keeps_everything() {
use time::macros::datetime;
let now = datetime!(2028-06-01 00:00 UTC);
let epoch = datetime!(1970-01-01 00:00 UTC);
for years in [u32::MAX, i32::MAX as u32, 100_000, 12_030, 10_000] {
let cutoff = Retention::CalendarYears(years).cutoff(now);
assert!(
cutoff < epoch,
"CalendarYears({years}) put the cutoff at {cutoff}, which makes \
readings due that are nowhere near the ceiling"
);
}
assert!(Retention::Rolling(time::Duration::MAX).cutoff(now) < epoch);
assert_eq!(
Retention::CalendarYears(3).cutoff(now),
datetime!(2024-12-31 23:00 UTC)
);
}
#[tokio::test]
async fn a_short_erasure_key_is_refused() {
assert!(SubjectRegistry::with_erasure_secret(lazy_pool(), &[0; 31]).is_err());
assert!(SubjectRegistry::with_erasure_secret(lazy_pool(), &[0; 32]).is_ok());
}
#[test]
fn an_erasure_record_carries_no_natural_identifier() {
let record = ErasureRecord {
subject: SubjectRef::new("s2026_abc").unwrap(),
erased_at: OffsetDateTime::UNIX_EPOCH,
reason: "DSAR-2026-0042".to_string(),
actor: "privacy-team".to_string(),
};
let rendered = format!("{record:?}");
assert!(rendered.contains("s2026_abc"));
assert!(rendered.contains("DSAR-2026-0042"));
}
}