///|
/// Catalog-level checks for retention rules and capsule inventories.
///
/// This module audits application data-lifecycle metadata only. It does not
/// inspect repositories, package registries, CI systems, or open-source
/// provenance.
pub(all) enum CatalogFindingKind {
DuplicateRuleId
DuplicateCapsuleId
InvalidRetentionWindow
MissingAllowedRegion
MissingRuleOwner
RuleWithoutCapsule
CapsuleWithoutRule
SensitiveExternalSharing
SensitivePlainCapsule
PublicSharedRestrictedCapsule
} derive(Eq, Debug)
///|
pub(all) struct CatalogFinding {
kind : CatalogFindingKind
severity : RetentionSeverity
subject : String
message : String
remedy : String
} derive(Eq, Debug)
///|
pub(all) struct CatalogReport {
rules : Array[RetentionRule]
capsules : Array[DataCapsule]
findings : Array[CatalogFinding]
covered_capsules : Int
uncovered_capsules : Int
} derive(Eq, Debug)
///|
pub fn CatalogFindingKind::label(self : CatalogFindingKind) -> String {
match self {
DuplicateRuleId => "duplicate-rule-id"
DuplicateCapsuleId => "duplicate-capsule-id"
InvalidRetentionWindow => "invalid-retention-window"
MissingAllowedRegion => "missing-allowed-region"
MissingRuleOwner => "missing-rule-owner"
RuleWithoutCapsule => "rule-without-capsule"
CapsuleWithoutRule => "capsule-without-rule"
SensitiveExternalSharing => "sensitive-external-sharing"
SensitivePlainCapsule => "sensitive-plain-capsule"
PublicSharedRestrictedCapsule => "public-shared-restricted-capsule"
}
}
///|
pub fn analyze_catalog(
rules : Array[RetentionRule],
capsules : Array[DataCapsule],
) -> CatalogReport {
let findings : Array[CatalogFinding] = []
audit_rule_ids(rules, findings)
audit_capsule_ids(capsules, findings)
audit_rules(rules, capsules, findings)
audit_capsules(rules, capsules, findings)
let uncovered_capsules = capsules.count_if(capsule => {
!catalog_has_rule(capsule, rules)
})
{
rules,
capsules,
findings: sort_catalog_findings(findings),
covered_capsules: capsules.length() - uncovered_capsules,
uncovered_capsules,
}
}
///|
pub fn sample_catalog_report() -> CatalogReport {
analyze_catalog(sample_rules(), sample_capsules())
}
///|
pub fn CatalogReport::is_ready(self : CatalogReport) -> Bool {
!self.findings.any(finding => {
finding.severity == RetentionCritical || finding.severity == RetentionHigh
})
}
///|
pub fn CatalogReport::coverage_percent(self : CatalogReport) -> Int {
if self.capsules.is_empty() {
100
} else {
self.covered_capsules * 100 / self.capsules.length()
}
}
///|
pub fn CatalogReport::count_by_severity(
self : CatalogReport,
severity : RetentionSeverity,
) -> Int {
self.findings.count_if(finding => finding.severity == severity)
}
///|
pub fn CatalogReport::count_by_kind(
self : CatalogReport,
kind : CatalogFindingKind,
) -> Int {
self.findings.count_if(finding => finding.kind == kind)
}
///|
pub fn CatalogReport::findings_for_subject(
self : CatalogReport,
subject : StringView,
) -> Array[CatalogFinding] {
let expected = subject.trim().to_owned()
self.findings.filter(finding => finding.subject == expected)
}
///|
pub fn CatalogReport::to_markdown(self : CatalogReport) -> String {
let lines : Array[String] = []
lines.push("# CapsuleTrace Catalog Report")
lines.push("")
lines.push("- Rules: " + self.rules.length().to_string())
lines.push("- Capsules: " + self.capsules.length().to_string())
lines.push("- Covered capsules: " + self.covered_capsules.to_string())
lines.push("- Coverage: " + self.coverage_percent().to_string() + "%")
lines.push("- Ready: " + bool_label(self.is_ready()))
lines.push("")
lines.push("| Severity | Rule | Subject | Message |")
lines.push("| --- | --- | --- | --- |")
if self.findings.is_empty() {
lines.push("| info | clean | all | no catalog findings |")
} else {
for finding in self.findings {
lines.push(
"| " +
finding.severity.label() +
" | " +
finding.kind.label() +
" | " +
markdown_cell(finding.subject) +
" | " +
markdown_cell(finding.message) +
" |",
)
}
}
lines.join("\n")
}
///|
pub fn CatalogReport::to_json_string(self : CatalogReport) -> String {
Json::object(
Map([
("ready", Json::boolean(self.is_ready())),
("coverage_percent", json_int(self.coverage_percent())),
("rules", json_int(self.rules.length())),
("capsules", json_int(self.capsules.length())),
("covered_capsules", json_int(self.covered_capsules)),
("uncovered_capsules", json_int(self.uncovered_capsules)),
("findings", catalog_findings_to_json(self.findings)),
]),
).stringify(indent=2)
}
///|
fn audit_rule_ids(
rules : Array[RetentionRule],
findings : Array[CatalogFinding],
) -> Unit {
let seen : Array[String] = []
for rule in rules {
if seen.contains(rule.id) {
findings.push({
kind: DuplicateRuleId,
severity: RetentionHigh,
subject: rule.id,
message: "retention rule id appears more than once",
remedy: "keep rule ids unique so matched decisions are deterministic",
})
} else {
seen.push(rule.id)
}
}
}
///|
fn audit_capsule_ids(
capsules : Array[DataCapsule],
findings : Array[CatalogFinding],
) -> Unit {
let seen : Array[String] = []
for capsule in capsules {
if seen.contains(capsule.id) {
findings.push({
kind: DuplicateCapsuleId,
severity: RetentionHigh,
subject: capsule.id,
message: "data capsule id appears more than once",
remedy: "keep capsule ids unique before building a deletion queue",
})
} else {
seen.push(capsule.id)
}
}
}
///|
fn audit_rules(
rules : Array[RetentionRule],
capsules : Array[DataCapsule],
findings : Array[CatalogFinding],
) -> Unit {
for rule in rules {
if rule.max_keep_days <= 0 {
findings.push({
kind: InvalidRetentionWindow,
severity: RetentionHigh,
subject: rule.id,
message: "max retention days must be positive",
remedy: "set a positive max_keep_days value",
})
}
if rule.anonymize_after_days <= 0 {
findings.push({
kind: InvalidRetentionWindow,
severity: RetentionMedium,
subject: rule.id,
message: "anonymization window should be positive",
remedy: "set anonymize_after_days to the first day identifiers should be reduced",
})
}
if rule.anonymize_after_days > rule.max_keep_days {
findings.push({
kind: InvalidRetentionWindow,
severity: RetentionHigh,
subject: rule.id,
message: "anonymization window is later than deletion deadline",
remedy: "make anonymization happen before or on the retention deadline",
})
}
if rule.allowed_regions.is_empty() {
findings.push({
kind: MissingAllowedRegion,
severity: RetentionHigh,
subject: rule.id,
message: "rule has no allowed storage region",
remedy: "add at least one allowed region",
})
}
if rule.owner.trim().is_empty() {
findings.push({
kind: MissingRuleOwner,
severity: RetentionMedium,
subject: rule.id,
message: "rule has no owner for operator handoff",
remedy: "assign an owner such as privacy, finance, support, or data-platform",
})
}
if !rule_has_capsule(rule, capsules) {
findings.push({
kind: RuleWithoutCapsule,
severity: RetentionLow,
subject: rule.id,
message: "rule is currently not used by any capsule",
remedy: "keep it if it is a planned policy; otherwise remove it from the catalog",
})
}
}
}
///|
fn audit_capsules(
rules : Array[RetentionRule],
capsules : Array[DataCapsule],
findings : Array[CatalogFinding],
) -> Unit {
for capsule in capsules {
if !catalog_has_rule(capsule, rules) {
findings.push({
kind: CapsuleWithoutRule,
severity: RetentionHigh,
subject: capsule.id,
message: "capsule has no matching retention rule",
remedy: "add a rule for purpose '" +
capsule.purpose +
"' and data kind '" +
capsule.data_kind +
"'",
})
}
if capsule.sensitivity.requires_encryption() && !capsule.encrypted {
findings.push({
kind: SensitivePlainCapsule,
severity: RetentionHigh,
subject: capsule.id,
message: "sensitive capsule is stored without encryption metadata",
remedy: "encrypt the capsule or downgrade the sensitivity only if appropriate",
})
}
if capsule.sensitivity.requires_encryption() &&
capsule.sharing.is_external() {
findings.push({
kind: SensitiveExternalSharing,
severity: RetentionMedium,
subject: capsule.id,
message: "sensitive capsule is shared outside the application team",
remedy: "confirm processor contracts or keep the capsule internal",
})
}
if capsule.sensitivity == RestrictedData && capsule.sharing == PublicShared {
findings.push({
kind: PublicSharedRestrictedCapsule,
severity: RetentionCritical,
subject: capsule.id,
message: "restricted capsule cannot be publicly shared",
remedy: "stop public sharing and rotate affected exports",
})
}
}
}
///|
fn rule_has_capsule(
rule : RetentionRule,
capsules : Array[DataCapsule],
) -> Bool {
capsules.any(capsule => rule_matches_capsule(rule, capsule))
}
///|
fn catalog_has_rule(
capsule : DataCapsule,
rules : Array[RetentionRule],
) -> Bool {
rules.any(rule => rule_matches_capsule(rule, capsule))
}
///|
fn rule_matches_capsule(rule : RetentionRule, capsule : DataCapsule) -> Bool {
normalize_token(rule.purpose) == normalize_token(capsule.purpose) &&
(
normalize_token(rule.data_kind) == normalize_token(capsule.data_kind) ||
normalize_token(rule.data_kind) == "*"
)
}
///|
fn sort_catalog_findings(
values : Array[CatalogFinding],
) -> Array[CatalogFinding] {
let mut sorted : Array[CatalogFinding] = []
for value in values {
let next : Array[CatalogFinding] = []
let mut inserted = false
for existing in sorted {
if !inserted && compare_catalog_finding(value, existing) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn compare_catalog_finding(a : CatalogFinding, b : CatalogFinding) -> Int {
let severity_delta = b.severity.weight() - a.severity.weight()
if severity_delta != 0 {
severity_delta
} else {
let subject_delta = a.subject.lexical_compare(b.subject)
if subject_delta != 0 {
subject_delta
} else {
a.kind.label().lexical_compare(b.kind.label())
}
}
}
///|
fn catalog_findings_to_json(items : Array[CatalogFinding]) -> 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())),
("subject", Json::string(item.subject)),
("message", Json::string(item.message)),
("remedy", Json::string(item.remedy)),
]),
),
)
}
Json::array(values)
}