///|
/// Purpose-drift checks for retained data capsules.
///
/// A capsule can be retained correctly but still be misused later for a purpose,
/// actor, region, or sharing mode outside the reason it was collected. This
/// module checks structured use events against explicit purpose rules.
pub(all) enum UseAction {
AllowUse
RequireReconsent
BlockUse
EscalateUse
} derive(Eq, Debug)
///|
pub(all) enum UseIssueKind {
UnknownCapsule
MissingPurposeRule
PurposeMismatch
ActorDenied
UseRegionDenied
ConsentForUseInvalid
UseSharingDenied
SensitivePublicUse
} derive(Eq, Debug)
///|
pub(all) struct PurposeRule {
id : String
source_purpose : String
allowed_use_purposes : Array[String]
allowed_actors : Array[String]
allowed_regions : Array[String]
allow_processor : Bool
allow_public : Bool
requires_fresh_consent : Bool
owner : String
note : String
} derive(Eq, Debug)
///|
pub(all) struct CapsuleUseEvent {
id : String
capsule_id : String
day : Int
use_purpose : String
actor : String
region : String
sharing : SharingMode
note : String
} derive(Eq, Debug)
///|
pub(all) struct UseIssue {
kind : UseIssueKind
severity : RetentionSeverity
message : String
remedy : String
} derive(Eq, Debug)
///|
pub(all) struct UseDecision {
event : CapsuleUseEvent
capsule_id : String
rule_id : String
action : UseAction
severity : RetentionSeverity
issues : Array[UseIssue]
owner : String
} derive(Eq, Debug)
///|
pub(all) struct PurposeDriftReport {
generated_day : Int
decisions : Array[UseDecision]
total_events : Int
allowed_count : Int
reconsent_count : Int
blocked_count : Int
escalated_count : Int
} derive(Eq, Debug)
///|
pub fn UseAction::label(self : UseAction) -> String {
match self {
AllowUse => "allow"
RequireReconsent => "require-reconsent"
BlockUse => "block"
EscalateUse => "escalate"
}
}
///|
pub fn UseAction::priority(self : UseAction) -> Int {
match self {
AllowUse => 0
RequireReconsent => 20
EscalateUse => 35
BlockUse => 50
}
}
///|
pub fn UseIssueKind::label(self : UseIssueKind) -> String {
match self {
UnknownCapsule => "unknown-capsule"
MissingPurposeRule => "missing-purpose-rule"
PurposeMismatch => "purpose-mismatch"
ActorDenied => "actor-denied"
UseRegionDenied => "use-region-denied"
ConsentForUseInvalid => "consent-for-use-invalid"
UseSharingDenied => "use-sharing-denied"
SensitivePublicUse => "sensitive-public-use"
}
}
///|
pub fn purpose_rule(
id : String,
source_purpose : String,
allowed_use_purposes : Array[String],
allowed_actors : Array[String],
allowed_regions : Array[String],
allow_processor : Bool,
allow_public : Bool,
requires_fresh_consent : Bool,
owner : String,
note : String,
) -> PurposeRule {
{
id,
source_purpose,
allowed_use_purposes,
allowed_actors,
allowed_regions,
allow_processor,
allow_public,
requires_fresh_consent,
owner,
note,
}
}
///|
pub fn capsule_use_event(
id : String,
capsule_id : String,
day : Int,
use_purpose : String,
actor : String,
region : String,
sharing : SharingMode,
note : String,
) -> CapsuleUseEvent {
{ id, capsule_id, day, use_purpose, actor, region, sharing, note }
}
///|
pub fn evaluate_use_event(
event : CapsuleUseEvent,
capsules : Array[DataCapsule],
rules : Array[PurposeRule],
) -> UseDecision {
match find_capsule_by_id(capsules, event.capsule_id) {
None =>
build_use_decision(event, event.capsule_id, "", "privacy-team", [
{
kind: UnknownCapsule,
severity: RetentionHigh,
message: "use event references an unknown capsule",
remedy: "drop the event or add the capsule inventory before processing",
},
])
Some(capsule) =>
match find_purpose_rule(capsule, rules) {
None =>
build_use_decision(event, capsule.id, "", "privacy-team", [
{
kind: MissingPurposeRule,
severity: RetentionHigh,
message: "no purpose rule exists for source purpose '" +
capsule.purpose +
"'",
remedy: "add a purpose rule before allowing secondary use",
},
])
Some(rule) => {
let issues : Array[UseIssue] = []
if !purpose_allowed(rule, event.use_purpose) {
issues.push({
kind: PurposeMismatch,
severity: if rule.requires_fresh_consent {
RetentionHigh
} else {
RetentionMedium
},
message: "use purpose '" +
event.use_purpose +
"' is not allowed for source purpose '" +
capsule.purpose +
"'",
remedy: "collect fresh consent or create a narrower derived capsule",
})
}
if !actor_allowed(rule, event.actor) {
issues.push({
kind: ActorDenied,
severity: RetentionHigh,
message: "actor '" +
event.actor +
"' is not allowed by purpose rule",
remedy: "route the request to an approved actor or add an explicit rule",
})
}
if !use_region_allowed(rule, event.region) {
issues.push({
kind: UseRegionDenied,
severity: RetentionHigh,
message: "use region '" + event.region + "' is not allowed",
remedy: "run the use in an approved region or quarantine the result",
})
}
if rule.requires_fresh_consent && !capsule.consent.allows_processing() {
issues.push({
kind: ConsentForUseInvalid,
severity: RetentionCritical,
message: "capsule consent does not allow this use",
remedy: "block the use and remove downstream artifacts",
})
}
if event.sharing == ProcessorShared && !rule.allow_processor {
issues.push({
kind: UseSharingDenied,
severity: RetentionHigh,
message: "processor sharing is denied for this purpose rule",
remedy: "keep processing internal or obtain a processor-approved rule",
})
}
if event.sharing == PublicShared && !rule.allow_public {
issues.push({
kind: UseSharingDenied,
severity: RetentionHigh,
message: "public sharing is denied for this purpose rule",
remedy: "do not publish this derived result",
})
}
if capsule.sensitivity.requires_encryption() &&
event.sharing == PublicShared {
issues.push({
kind: SensitivePublicUse,
severity: RetentionCritical,
message: "sensitive capsule is used in a public sharing path",
remedy: "block public output and create an aggregated anonymous result",
})
}
build_use_decision(event, capsule.id, rule.id, rule.owner, issues)
}
}
}
}
///|
pub fn analyze_purpose_drift(
events : Array[CapsuleUseEvent],
capsules : Array[DataCapsule],
rules : Array[PurposeRule],
today : Int,
) -> PurposeDriftReport {
let decisions : Array[UseDecision] = []
for event in events {
decisions.push(evaluate_use_event(event, capsules, rules))
}
let decisions = sort_use_decisions(decisions)
{
generated_day: today,
total_events: events.length(),
allowed_count: decisions.count_if(decision => decision.action == AllowUse),
reconsent_count: decisions.count_if(decision => {
decision.action == RequireReconsent
}),
blocked_count: decisions.count_if(decision => decision.action == BlockUse),
escalated_count: decisions.count_if(decision => {
decision.action == EscalateUse
}),
decisions,
}
}
///|
pub fn sample_purpose_rules() -> Array[PurposeRule] {
[
purpose_rule(
"purpose.analytics",
"analytics",
["analytics", "product-quality"],
["product", "privacy-analytics"],
["cn", "eu"],
false,
false,
true,
"privacy-analytics",
"analytics capsules may be reused for product quality only",
),
purpose_rule(
"purpose.support",
"support",
["support", "quality-review"],
["support", "support-ops"],
["cn", "sg"],
true,
false,
false,
"support-ops",
"support tickets may be reviewed by approved processors",
),
purpose_rule(
"purpose.billing",
"billing",
["billing", "tax"],
["finance"],
["cn"],
false,
false,
false,
"finance",
"billing capsules stay local and finance-owned",
),
]
}
///|
pub fn sample_use_events() -> Array[CapsuleUseEvent] {
[
capsule_use_event(
"use.allowed",
"cap.active-analytics",
221,
"product-quality",
"product",
"cn",
InternalOnly,
"allowed quality improvement use",
),
capsule_use_event(
"use.ads-drift",
"cap.active-analytics",
222,
"ads",
"growth",
"us",
ProcessorShared,
"purpose drift into advertising",
),
capsule_use_event(
"use.public-ticket",
"cap.unencrypted-ticket",
223,
"quality-review",
"support",
"sg",
PublicShared,
"public sharing should be blocked",
),
capsule_use_event(
"use.unknown",
"cap.missing",
224,
"analytics",
"product",
"cn",
InternalOnly,
"unknown capsule id",
),
]
}
///|
pub fn sample_purpose_drift_report() -> PurposeDriftReport {
analyze_purpose_drift(
sample_use_events(),
sample_capsules(),
sample_purpose_rules(),
225,
)
}
///|
pub fn PurposeDriftReport::is_ready(self : PurposeDriftReport) -> Bool {
self.blocked_count == 0 && self.escalated_count == 0
}
///|
pub fn PurposeDriftReport::decisions_for_action(
self : PurposeDriftReport,
action : UseAction,
) -> Array[UseDecision] {
self.decisions.filter(decision => decision.action == action)
}
///|
pub fn PurposeDriftReport::decision(
self : PurposeDriftReport,
event_id : StringView,
) -> UseDecision? {
let expected = event_id.trim().to_owned()
self.decisions.iter().find_first(decision => decision.event.id == expected)
}
///|
pub fn PurposeDriftReport::to_markdown(self : PurposeDriftReport) -> String {
let lines : Array[String] = []
lines.push("# CapsuleTrace Purpose Drift Report")
lines.push("")
lines.push("- Generated day: " + self.generated_day.to_string())
lines.push("- Events: " + self.total_events.to_string())
lines.push("- Allowed: " + self.allowed_count.to_string())
lines.push("- Require reconsent: " + self.reconsent_count.to_string())
lines.push("- Escalated: " + self.escalated_count.to_string())
lines.push("- Blocked: " + self.blocked_count.to_string())
lines.push("- Ready: " + bool_label(self.is_ready()))
lines.push("")
lines.push(
"| Event | Capsule | Use Purpose | Actor | Region | Action | Severity | Issues |",
)
lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |")
if self.decisions.is_empty() {
lines.push("| - | - | - | - | - | allow | info | no events |")
} else {
for decision in self.decisions {
lines.push(
"| " +
markdown_cell(decision.event.id) +
" | " +
markdown_cell(decision.capsule_id) +
" | " +
markdown_cell(decision.event.use_purpose) +
" | " +
markdown_cell(decision.event.actor) +
" | " +
markdown_cell(decision.event.region) +
" | " +
decision.action.label() +
" | " +
decision.severity.label() +
" | " +
markdown_cell(decision.issue_summary()) +
" |",
)
}
}
lines.join("\n")
}
///|
pub fn PurposeDriftReport::to_json_string(self : PurposeDriftReport) -> String {
Json::object(
Map([
("generated_day", json_int(self.generated_day)),
("ready", Json::boolean(self.is_ready())),
("total_events", json_int(self.total_events)),
("allowed_count", json_int(self.allowed_count)),
("reconsent_count", json_int(self.reconsent_count)),
("blocked_count", json_int(self.blocked_count)),
("escalated_count", json_int(self.escalated_count)),
("decisions", use_decisions_to_json(self.decisions)),
]),
).stringify(indent=2)
}
///|
pub fn UseDecision::issue_summary(self : UseDecision) -> String {
if self.issues.is_empty() {
"within purpose rule"
} else {
let labels : Array[String] = []
for issue in self.issues {
labels.push(issue.kind.label())
}
labels.join(", ")
}
}
///|
fn build_use_decision(
event : CapsuleUseEvent,
capsule_id : String,
rule_id : String,
owner : String,
issues : Array[UseIssue],
) -> UseDecision {
{
event,
capsule_id,
rule_id,
action: action_for_use_issues(issues),
severity: severity_for_use_issues(issues),
issues,
owner,
}
}
///|
fn action_for_use_issues(issues : Array[UseIssue]) -> UseAction {
let mut action = AllowUse
for issue in issues {
let next = action_for_use_issue(issue)
if next.priority() > action.priority() {
action = next
}
}
action
}
///|
fn action_for_use_issue(issue : UseIssue) -> UseAction {
match issue.kind {
UnknownCapsule => EscalateUse
MissingPurposeRule => EscalateUse
PurposeMismatch =>
if issue.severity == RetentionHigh || issue.severity == RetentionCritical {
RequireReconsent
} else {
EscalateUse
}
ActorDenied => EscalateUse
UseRegionDenied => EscalateUse
ConsentForUseInvalid => BlockUse
UseSharingDenied => EscalateUse
SensitivePublicUse => BlockUse
}
}
///|
fn severity_for_use_issues(issues : Array[UseIssue]) -> RetentionSeverity {
let mut severity = RetentionInfo
for issue in issues {
if issue.severity.weight() > severity.weight() {
severity = issue.severity
}
}
severity
}
///|
fn find_capsule_by_id(
capsules : Array[DataCapsule],
id : String,
) -> DataCapsule? {
capsules.iter().find_first(capsule => capsule.id == id)
}
///|
fn find_purpose_rule(
capsule : DataCapsule,
rules : Array[PurposeRule],
) -> PurposeRule? {
let purpose = normalize_token(capsule.purpose)
rules
.iter()
.find_first(rule => normalize_token(rule.source_purpose) == purpose)
}
///|
fn purpose_allowed(rule : PurposeRule, purpose : String) -> Bool {
list_contains_normalized(rule.allowed_use_purposes, purpose)
}
///|
fn actor_allowed(rule : PurposeRule, actor : String) -> Bool {
list_contains_normalized(rule.allowed_actors, actor)
}
///|
fn use_region_allowed(rule : PurposeRule, region : String) -> Bool {
list_contains_normalized(rule.allowed_regions, region)
}
///|
fn list_contains_normalized(items : Array[String], value : String) -> Bool {
let expected = normalize_token(value)
items.any(item => normalize_token(item) == expected)
}
///|
fn sort_use_decisions(values : Array[UseDecision]) -> Array[UseDecision] {
let mut sorted : Array[UseDecision] = []
for value in values {
let next : Array[UseDecision] = []
let mut inserted = false
for existing in sorted {
if !inserted && compare_use_decision(value, existing) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn compare_use_decision(a : UseDecision, b : UseDecision) -> Int {
let action_delta = b.action.priority() - a.action.priority()
if action_delta != 0 {
action_delta
} else {
a.event.id.lexical_compare(b.event.id)
}
}
///|
fn use_decisions_to_json(items : Array[UseDecision]) -> Json {
let values : Array[Json] = []
for item in items {
values.push(
Json::object(
Map([
("event_id", Json::string(item.event.id)),
("capsule_id", Json::string(item.capsule_id)),
("rule_id", Json::string(item.rule_id)),
("use_purpose", Json::string(item.event.use_purpose)),
("actor", Json::string(item.event.actor)),
("region", Json::string(item.event.region)),
("sharing", Json::string(item.event.sharing.label())),
("action", Json::string(item.action.label())),
("severity", Json::string(item.severity.label())),
("owner", Json::string(item.owner)),
("issues", use_issues_to_json(item.issues)),
]),
),
)
}
Json::array(values)
}
///|
fn use_issues_to_json(items : Array[UseIssue]) -> Json {
let values : Array[Json] = []
for item in items {
values.push(
Json::object(
Map([
("kind", Json::string(item.kind.label())),
("severity", Json::string(item.severity.label())),
("message", Json::string(item.message)),
("remedy", Json::string(item.remedy)),
]),
),
)
}
Json::array(values)
}