///|
/// Safe export helpers for audit and review systems. Export records contain
/// finding metadata and replacement values, but never emit the original source
/// text unless a caller explicitly selects an authorized raw format.
pub(all) enum AuditExportFormat {
AuditExportJson
AuditExportCsv
AuditExportNdjson
AuditExportSummary
} derive(Debug, Eq)
///|
pub(all) enum AuditExportRedaction {
AuditExportMasked
AuditExportTokenOnly
AuditExportMetadataOnly
} derive(Debug, Eq)
///|
pub(all) struct AuditExportRequest {
export_id : String
document_id : String
requested_by : String
format : AuditExportFormat
redaction : AuditExportRedaction
query : FindingQuery
purpose : String
requested_at : String
max_records : Int
} derive(Debug)
///|
pub(all) struct AuditExportRecord {
finding_id : String
document_id : String
kind : String
rule_id : String
label : String
start : Int
end : Int
confidence : Int
risk : String
replacement : String
source_checksum : String
} derive(Debug, Eq)
///|
pub(all) struct AuditExport {
export_id : String
document_id : String
format : AuditExportFormat
redaction : AuditExportRedaction
records : Array[AuditExportRecord]
total_matches : Int
truncated : Bool
generated_at : String
checksum : String
} derive(Debug)
///|
pub fn audit_export_format_name(format : AuditExportFormat) -> String {
match format {
AuditExportJson => "json"
AuditExportCsv => "csv"
AuditExportNdjson => "ndjson"
AuditExportSummary => "summary"
}
}
///|
pub fn audit_export_redaction_name(redaction : AuditExportRedaction) -> String {
match redaction {
AuditExportMasked => "masked"
AuditExportTokenOnly => "token-only"
AuditExportMetadataOnly => "metadata-only"
}
}
///|
pub fn audit_export_request(
export_id : String,
document_id : String,
requested_by : String,
format : AuditExportFormat,
redaction : AuditExportRedaction,
purpose : String,
requested_at : String,
) -> AuditExportRequest {
{
export_id,
document_id,
requested_by,
format,
redaction,
query: FindingQuery::default(),
purpose,
requested_at,
max_records: 10000,
}
}
///|
pub fn AuditExportRequest::with_query(
request : AuditExportRequest,
query : FindingQuery,
) -> AuditExportRequest {
{ ..request, query, }
}
///|
pub fn AuditExportRequest::with_limit(
request : AuditExportRequest,
max_records : Int,
) -> AuditExportRequest {
{ ..request, max_records: if max_records < 1 { 1 } else { max_records } }
}
///|
pub fn AuditExportRequest::is_valid(self : AuditExportRequest) -> Bool {
self.export_id.length() > 0 &&
self.document_id.length() > 0 &&
self.requested_by.length() > 0 &&
self.purpose.length() > 0 &&
self.requested_at.length() > 0 &&
self.max_records > 0
}
///|
fn audit_export_record(
document_id : String,
finding : Finding,
redaction : AuditExportRedaction,
) -> AuditExportRecord {
let replacement = match redaction {
AuditExportMasked => finding.replacement
AuditExportTokenOnly => "[" + phi_kind_name(finding.kind) + "]"
AuditExportMetadataOnly => ""
}
{
finding_id: finding.id,
document_id,
kind: phi_kind_name(finding.kind),
rule_id: finding.rule_id,
label: finding.label,
start: finding.start,
end: finding.end,
confidence: finding.confidence,
risk: "\{risk_level(finding)}",
replacement,
source_checksum: stable_hash(finding.text),
}
}
///|
pub fn build_audit_export(
request : AuditExportRequest,
findings : Array[Finding],
generated_at : String,
) -> AuditExport {
let selected = query_findings(findings, request.query)
let limited = if selected.length() > request.max_records {
selected[:request.max_records].to_owned()
} else {
selected
}
let records = limited.map(fn(finding) {
audit_export_record(request.document_id, finding, request.redaction)
})
{
export_id: request.export_id,
document_id: request.document_id,
format: request.format,
redaction: request.redaction,
records,
total_matches: selected.length(),
truncated: selected.length() > limited.length(),
generated_at,
checksum: stable_hash(
records.map(fn(record) { record.finding_id }).join("\n"),
),
}
}
///|
pub fn AuditExport::is_empty(self : AuditExport) -> Bool {
self.records.is_empty()
}
///|
pub fn AuditExport::record_count(self : AuditExport) -> Int {
self.records.length()
}
///|
pub fn AuditExport::metadata_only(self : AuditExport) -> Bool {
self.redaction == AuditExportMetadataOnly
}
///|
pub fn AuditExport::contains_raw_text(self : AuditExport) -> Bool {
self.records.any(fn(record) {
record.replacement.contains(record.source_checksum)
})
}
///|
fn audit_export_csv_escape(value : String) -> String {
if value.contains(",") || value.contains("\"") || value.contains("\n") {
"\"" + value.replace(old="\"", new="\"\"") + "\""
} else {
value
}
}
///|
fn audit_export_record_csv(record : AuditExportRecord) -> String {
[
record.finding_id,
record.document_id,
record.kind,
record.rule_id,
record.label,
record.start.to_string(),
record.end.to_string(),
record.confidence.to_string(),
record.risk,
record.replacement,
record.source_checksum,
]
.map(audit_export_csv_escape)
.join(",")
}
///|
pub fn AuditExport::to_csv(self : AuditExport) -> String {
let header = "finding_id,document_id,kind,rule_id,label,start,end,confidence,risk,replacement,source_checksum"
let rows = self.records.map(audit_export_record_csv)
([header] + rows).join("\n")
}
///|
fn audit_export_record_json(record : AuditExportRecord) -> String {
"{" +
"\"finding_id\":\"" +
json_escape(record.finding_id) +
"\"," +
"\"document_id\":\"" +
json_escape(record.document_id) +
"\"," +
"\"kind\":\"" +
json_escape(record.kind) +
"\"," +
"\"rule_id\":\"" +
json_escape(record.rule_id) +
"\"," +
"\"label\":\"" +
json_escape(record.label) +
"\"," +
"\"start\":" +
record.start.to_string() +
"," +
"\"end\":" +
record.end.to_string() +
"," +
"\"confidence\":" +
record.confidence.to_string() +
"," +
"\"risk\":\"" +
json_escape(record.risk) +
"\"," +
"\"replacement\":\"" +
json_escape(record.replacement) +
"\"," +
"\"source_checksum\":\"" +
json_escape(record.source_checksum) +
"\"}"
}
///|
pub fn AuditExport::to_json(self : AuditExport) -> String {
"{" +
"\"export_id\":\"" +
json_escape(self.export_id) +
"\"," +
"\"document_id\":\"" +
json_escape(self.document_id) +
"\"," +
"\"format\":\"" +
audit_export_format_name(self.format) +
"\"," +
"\"redaction\":\"" +
audit_export_redaction_name(self.redaction) +
"\"," +
"\"records\":[" +
self.records.map(audit_export_record_json).join(",") +
"]," +
"\"total_matches\":" +
self.total_matches.to_string() +
"," +
"\"truncated\":" +
self.truncated.to_string() +
"," +
"\"generated_at\":\"" +
json_escape(self.generated_at) +
"\"," +
"\"checksum\":\"" +
json_escape(self.checksum) +
"\"}"
}
///|
pub fn AuditExport::to_ndjson(self : AuditExport) -> String {
self.records.map(audit_export_record_json).join("\n")
}
///|
pub fn AuditExport::summary(self : AuditExport) -> String {
[
"export_id=" + self.export_id,
"document_id=" + self.document_id,
"format=" + audit_export_format_name(self.format),
"redaction=" + audit_export_redaction_name(self.redaction),
"records=" + self.records.length().to_string(),
"total_matches=" + self.total_matches.to_string(),
"truncated=" + self.truncated.to_string(),
"generated_at=" + self.generated_at,
"checksum=" + self.checksum,
].join("\n")
}
///|
pub fn AuditExport::render(self : AuditExport) -> String {
match self.format {
AuditExportJson => self.to_json()
AuditExportCsv => self.to_csv()
AuditExportNdjson => self.to_ndjson()
AuditExportSummary => self.summary()
}
}
///|
pub fn audit_export_is_safe(artifact : AuditExport) -> Bool {
!artifact.contains_raw_text() &&
artifact.records.all(fn(record) {
record.source_checksum.length() > 0 && record.finding_id.length() > 0
})
}
///|
pub fn audit_export_kind_counts(artifact : AuditExport) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for record in artifact.records {
counts[record.kind] = counts.get_or_default(record.kind, 0) + 1
}
counts
}
///|
pub fn audit_export_risk_counts(artifact : AuditExport) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for record in artifact.records {
counts[record.risk] = counts.get_or_default(record.risk, 0) + 1
}
counts
}
///|
pub fn audit_export_checksum(artifact : AuditExport) -> String {
stable_hash(artifact.to_ndjson())
}
///|
pub fn audit_export_round_trip_stable(artifact : AuditExport) -> Bool {
artifact.to_json().length() > 0 &&
audit_export_checksum(artifact).length() > 0
}
///|
pub fn audit_export_limit_is_enforced(
request : AuditExportRequest,
findings : Array[Finding],
) -> Bool {
build_audit_export(request, findings, request.requested_at).records.length() <=
request.max_records
}