///|
/// Retention and purge planning keeps privacy operations reproducible. The
/// planner produces candidates and evidence; a storage adapter performs the
/// irreversible action after a separate authorization check.
pub(all) enum ArchiveRetentionClass {
ArchiveClinicalRecord
ArchiveAuditRecord
ArchiveReviewRecord
ArchiveMetricRecord
ArchiveTemporaryRecord
} derive(Debug, Eq)
///|
pub(all) enum ArchiveRetentionAction {
ArchiveKeep
ArchivePurge
ArchiveArchive
ArchiveHold
ArchiveReview
} derive(Debug, Eq)
///|
pub(all) struct ArchiveRetentionRule {
rule_id : String
class : ArchiveRetentionClass
retention_days : Int
action : ArchiveRetentionAction
legal_basis : String
owner : String
} derive(Debug, Eq)
///|
pub(all) struct ArchiveRecord {
record_id : String
class : ArchiveRetentionClass
created_at : String
last_accessed_at : String
checksum : String
legal_hold : Bool
owner : String
tags : Array[String]
} derive(Debug, Eq)
///|
pub(all) struct ArchivePurgeCandidate {
record : ArchiveRecord
rule : ArchiveRetentionRule
action : ArchiveRetentionAction
reason : String
authorization_checksum : String
} derive(Debug)
///|
pub(all) struct ArchiveRetentionPlan {
plan_id : String
as_of : String
candidates : Array[ArchivePurgeCandidate]
held_records : Array[String]
unknown_records : Array[String]
checksum : String
} derive(Debug)
///|
pub fn archive_retention_class_name(class : ArchiveRetentionClass) -> String {
match class {
ArchiveClinicalRecord => "clinical"
ArchiveAuditRecord => "audit"
ArchiveReviewRecord => "review"
ArchiveMetricRecord => "metric"
ArchiveTemporaryRecord => "temporary"
}
}
///|
pub fn archive_retention_action_name(action : ArchiveRetentionAction) -> String {
match action {
ArchiveKeep => "keep"
ArchivePurge => "purge"
ArchiveArchive => "archive"
ArchiveHold => "hold"
ArchiveReview => "review"
}
}
///|
pub fn archive_retention_rule(
rule_id : String,
class : ArchiveRetentionClass,
retention_days : Int,
action : ArchiveRetentionAction,
legal_basis : String,
owner : String,
) -> ArchiveRetentionRule {
{
rule_id,
class,
retention_days: if retention_days < 0 {
0
} else {
retention_days
},
action,
legal_basis,
owner,
}
}
///|
pub fn ArchiveRetentionRule::is_valid(self : ArchiveRetentionRule) -> Bool {
self.rule_id.length() > 0 &&
self.retention_days >= 0 &&
self.legal_basis.length() > 0 &&
self.owner.length() > 0
}
///|
pub fn archive_default_retention_rules() -> Array[ArchiveRetentionRule] {
[
archive_retention_rule(
"clinical-default",
ArchiveClinicalRecord,
2555,
ArchiveKeep,
"care-continuity",
"privacy",
),
archive_retention_rule(
"audit-default",
ArchiveAuditRecord,
2555,
ArchiveArchive,
"accountability",
"privacy",
),
archive_retention_rule(
"review-default",
ArchiveReviewRecord,
365,
ArchiveArchive,
"quality-assurance",
"quality",
),
archive_retention_rule(
"metric-default",
ArchiveMetricRecord,
730,
ArchiveArchive,
"operations",
"analytics",
),
archive_retention_rule(
"temporary-default",
ArchiveTemporaryRecord,
30,
ArchivePurge,
"data-minimization",
"platform",
),
]
}
///|
pub fn archive_record(
record_id : String,
class : ArchiveRetentionClass,
created_at : String,
last_accessed_at : String,
checksum : String,
owner : String,
) -> ArchiveRecord {
{
record_id,
class,
created_at,
last_accessed_at,
checksum,
legal_hold: false,
owner,
tags: [],
}
}
///|
pub fn ArchiveRecord::with_hold(self : ArchiveRecord) -> ArchiveRecord {
{ ..self, legal_hold: true }
}
///|
pub fn ArchiveRecord::with_tag(
record : ArchiveRecord,
tag : String,
) -> ArchiveRecord {
if !record.tags.contains(tag) {
record.tags.push(tag)
}
record
}
///|
pub fn ArchiveRecord::is_valid(self : ArchiveRecord) -> Bool {
self.record_id.length() > 0 &&
self.created_at.length() > 0 &&
self.checksum.length() > 0 &&
self.owner.length() > 0
}
///|
fn archive_rule_for(
rules : Array[ArchiveRetentionRule],
class : ArchiveRetentionClass,
) -> ArchiveRetentionRule? {
let mut selected : ArchiveRetentionRule? = None
for rule in rules {
if rule.class == class && rule.is_valid() {
selected = Some(rule)
}
}
selected
}
///|
pub fn archive_is_expired(
record : ArchiveRecord,
rule : ArchiveRetentionRule,
as_of : String,
) -> Bool {
record.is_valid() &&
rule.is_valid() &&
!record.legal_hold &&
as_of > record.created_at &&
record.created_at != as_of
}
///|
pub fn archive_candidate(
record : ArchiveRecord,
rule : ArchiveRetentionRule,
action : ArchiveRetentionAction,
) -> ArchivePurgeCandidate {
{
record,
rule,
action,
reason: archive_retention_class_name(record.class) +
" exceeded policy window",
authorization_checksum: stable_hash(
record.record_id +
":" +
rule.rule_id +
":" +
archive_retention_action_name(action),
),
}
}
///|
pub fn build_archive_plan(
plan_id : String,
as_of : String,
records : Array[ArchiveRecord],
rules : Array[ArchiveRetentionRule],
) -> ArchiveRetentionPlan {
let candidates : Array[ArchivePurgeCandidate] = []
let held : Array[String] = []
let unknown : Array[String] = []
for record in records {
match archive_rule_for(rules, record.class) {
None => unknown.push(record.record_id)
Some(rule) =>
if record.legal_hold {
held.push(record.record_id)
} else if archive_is_expired(record, rule, as_of) {
candidates.push(archive_candidate(record, rule, rule.action))
}
}
}
{
plan_id,
as_of,
candidates,
held_records: held,
unknown_records: unknown,
checksum: stable_hash(
candidates.map(fn(item) { item.authorization_checksum }).join("\n"),
),
}
}
///|
pub fn ArchiveRetentionPlan::purge_candidates(
self : ArchiveRetentionPlan,
) -> Array[ArchivePurgeCandidate] {
self.candidates.filter(fn(item) { item.action == ArchivePurge })
}
///|
pub fn ArchiveRetentionPlan::archive_candidates(
self : ArchiveRetentionPlan,
) -> Array[ArchivePurgeCandidate] {
self.candidates.filter(fn(item) { item.action == ArchiveArchive })
}
///|
pub fn ArchiveRetentionPlan::review_candidates(
self : ArchiveRetentionPlan,
) -> Array[ArchivePurgeCandidate] {
self.candidates.filter(fn(item) { item.action == ArchiveReview })
}
///|
pub fn ArchiveRetentionPlan::is_safe(self : ArchiveRetentionPlan) -> Bool {
self.candidates.all(fn(item) {
!item.record.legal_hold &&
item.record.checksum.length() > 0 &&
item.authorization_checksum.length() > 0
})
}
///|
pub fn ArchiveRetentionPlan::summary(self : ArchiveRetentionPlan) -> String {
[
"plan_id=" + self.plan_id,
"as_of=" + self.as_of,
"candidates=" + self.candidates.length().to_string(),
"purge=" + self.purge_candidates().length().to_string(),
"archive=" + self.archive_candidates().length().to_string(),
"review=" + self.review_candidates().length().to_string(),
"held=" + self.held_records.length().to_string(),
"unknown=" + self.unknown_records.length().to_string(),
"safe=" + self.is_safe().to_string(),
"checksum=" + self.checksum,
].join("\n")
}
///|
pub fn ArchiveRetentionPlan::to_json(self : ArchiveRetentionPlan) -> String {
"{" +
"\"plan_id\":\"" +
json_escape(self.plan_id) +
"\"," +
"\"as_of\":\"" +
json_escape(self.as_of) +
"\"," +
"\"candidates\":[" +
self.candidates
.map(fn(item) {
"{\"record_id\":\"" +
json_escape(item.record.record_id) +
"\",\"action\":\"" +
archive_retention_action_name(item.action) +
"\",\"authorization_checksum\":\"" +
json_escape(item.authorization_checksum) +
"\"}"
})
.join(",") +
"]," +
"\"held\":" +
self.held_records.length().to_string() +
"," +
"\"unknown\":" +
self.unknown_records.length().to_string() +
"," +
"\"checksum\":\"" +
json_escape(self.checksum) +
"\"}"
}
///|
pub fn archive_plan_is_reproducible(
left : ArchiveRetentionPlan,
right : ArchiveRetentionPlan,
) -> Bool {
left.plan_id == right.plan_id &&
left.as_of == right.as_of &&
left.checksum == right.checksum
}
///|
pub fn archive_plan_requires_authorization(plan : ArchiveRetentionPlan) -> Bool {
!plan.purge_candidates().is_empty() || !plan.archive_candidates().is_empty()
}
///|
pub fn archive_plan_has_unknowns(plan : ArchiveRetentionPlan) -> Bool {
!plan.unknown_records.is_empty()
}
///|
pub fn archive_plan_record_ids(plan : ArchiveRetentionPlan) -> Array[String] {
plan.candidates.map(fn(item) { item.record.record_id })
}
///|
pub fn archive_plan_authorization_checksum(
plan : ArchiveRetentionPlan,
) -> String {
stable_hash(
plan.candidates.map(fn(item) { item.authorization_checksum }).join("\n"),
)
}
///|
pub fn archive_records_by_owner(
records : Array[ArchiveRecord],
owner : String,
) -> Array[ArchiveRecord] {
records.filter(fn(record) { record.owner == owner })
}
///|
pub fn archive_records_with_hold(
records : Array[ArchiveRecord],
) -> Array[ArchiveRecord] {
records.filter(fn(record) { record.legal_hold })
}
///|
pub fn archive_retention_class_counts(
records : Array[ArchiveRecord],
) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for record in records {
let key = archive_retention_class_name(record.class)
counts[key] = counts.get_or_default(key, 0) + 1
}
counts
}
///|
pub fn archive_plan_is_monotonic(
before : ArchiveRetentionPlan,
after : ArchiveRetentionPlan,
) -> Bool {
after.candidates.length() >= before.candidates.length() &&
before.held_records.all(fn(record_id) {
after.held_records.contains(record_id)
})
}