///|
/// Convert retention decisions into owner-oriented operator tasks.
pub(all) enum OperatorTaskStatus {
TaskTodo
TaskBlocked
TaskDone
} derive(Eq, Debug)
///|
pub(all) struct OperatorTask {
id : String
owner : String
action : RetentionAction
severity : RetentionSeverity
due_day : Int
capsule_ids : Array[String]
reason_kinds : Array[String]
checklist : Array[String]
status : OperatorTaskStatus
} derive(Eq, Debug)
///|
pub(all) struct OperatorPlaybook {
generated_day : Int
tasks : Array[OperatorTask]
} derive(Eq, Debug)
///|
pub fn OperatorTaskStatus::label(self : OperatorTaskStatus) -> String {
match self {
TaskTodo => "todo"
TaskBlocked => "blocked"
TaskDone => "done"
}
}
///|
pub fn build_operator_playbook(plan : RetentionPlan) -> OperatorPlaybook {
let tasks : Array[OperatorTask] = []
for decision in plan.decisions {
if decision.needs_operator_action() {
let due_day = operator_due_day(plan.generated_day, decision)
match
find_operator_task(
tasks,
decision.owner,
decision.action,
decision.severity,
due_day,
) {
Some(index) => merge_decision_into_task(tasks[index], decision)
None => tasks.push(task_from_decision(plan.generated_day, decision))
}
}
}
{ generated_day: plan.generated_day, tasks: sort_operator_tasks(tasks) }
}
///|
pub fn sample_playbook() -> OperatorPlaybook {
build_operator_playbook(sample_plan())
}
///|
pub fn OperatorPlaybook::is_empty(self : OperatorPlaybook) -> Bool {
self.tasks.is_empty()
}
///|
pub fn OperatorPlaybook::tasks_for_owner(
self : OperatorPlaybook,
owner : StringView,
) -> Array[OperatorTask] {
let expected = owner.trim().to_owned()
self.tasks.filter(task => task.owner == expected)
}
///|
pub fn OperatorPlaybook::urgent_tasks(
self : OperatorPlaybook,
) -> Array[OperatorTask] {
self.tasks.filter(task => {
task.severity == RetentionCritical || task.severity == RetentionHigh
})
}
///|
pub fn OperatorPlaybook::count_by_action(
self : OperatorPlaybook,
action : RetentionAction,
) -> Int {
self.tasks.count_if(task => task.action == action)
}
///|
pub fn OperatorPlaybook::mark_done(
self : OperatorPlaybook,
task_id : StringView,
) -> OperatorPlaybook {
change_task_status(self, task_id, TaskDone)
}
///|
pub fn OperatorPlaybook::mark_blocked(
self : OperatorPlaybook,
task_id : StringView,
) -> OperatorPlaybook {
change_task_status(self, task_id, TaskBlocked)
}
///|
pub fn OperatorPlaybook::to_markdown(self : OperatorPlaybook) -> String {
let lines : Array[String] = []
lines.push("# CapsuleTrace Operator Playbook")
lines.push("")
lines.push("- Generated day: " + self.generated_day.to_string())
lines.push("- Tasks: " + self.tasks.length().to_string())
lines.push("- Urgent: " + self.urgent_tasks().length().to_string())
lines.push("")
lines.push("| Task | Owner | Action | Severity | Due | Capsules | Status |")
lines.push("| --- | --- | --- | --- | --- | --- | --- |")
if self.tasks.is_empty() {
lines.push("| - | - | keep | info | - | - | done |")
} else {
for task in self.tasks {
lines.push(
"| " +
markdown_cell(task.id) +
" | " +
markdown_cell(task.owner) +
" | " +
task.action.label() +
" | " +
task.severity.label() +
" | " +
task.due_day.to_string() +
" | " +
markdown_cell(sort_strings(task.capsule_ids).join(", ")) +
" | " +
task.status.label() +
" |",
)
}
}
lines.push("")
lines.push("## Checklists")
lines.push("")
for task in self.tasks {
lines.push("### " + task.id)
for item in task.checklist {
lines.push("- [ ] " + item)
}
lines.push("")
}
lines.join("\n")
}
///|
pub fn OperatorPlaybook::to_json_string(self : OperatorPlaybook) -> String {
Json::object(
Map([
("generated_day", json_int(self.generated_day)),
("task_count", json_int(self.tasks.length())),
("urgent_count", json_int(self.urgent_tasks().length())),
("tasks", operator_tasks_to_json(self.tasks)),
]),
).stringify(indent=2)
}
///|
fn change_task_status(
playbook : OperatorPlaybook,
task_id : StringView,
status : OperatorTaskStatus,
) -> OperatorPlaybook {
let expected = task_id.trim().to_owned()
let tasks : Array[OperatorTask] = []
for task in playbook.tasks {
if task.id == expected {
tasks.push({
id: task.id,
owner: task.owner,
action: task.action,
severity: task.severity,
due_day: task.due_day,
capsule_ids: task.capsule_ids,
reason_kinds: task.reason_kinds,
checklist: task.checklist,
status,
})
} else {
tasks.push(task)
}
}
{ generated_day: playbook.generated_day, tasks }
}
///|
fn task_from_decision(today : Int, decision : CapsuleDecision) -> OperatorTask {
let due_day = operator_due_day(today, decision)
{
id: task_id(decision.owner, decision.action, decision.severity, due_day),
owner: decision.owner,
action: decision.action,
severity: decision.severity,
due_day,
capsule_ids: [decision.capsule.id],
reason_kinds: reason_kind_labels(decision.reasons),
checklist: checklist_for(decision.action),
status: TaskTodo,
}
}
///|
fn merge_decision_into_task(
task : OperatorTask,
decision : CapsuleDecision,
) -> Unit {
if !task.capsule_ids.contains(decision.capsule.id) {
task.capsule_ids.push(decision.capsule.id)
}
for reason in reason_kind_labels(decision.reasons) {
if !task.reason_kinds.contains(reason) {
task.reason_kinds.push(reason)
}
}
}
///|
fn find_operator_task(
tasks : Array[OperatorTask],
owner : String,
action : RetentionAction,
severity : RetentionSeverity,
due_day : Int,
) -> Int? {
for index, task in tasks.iter2() {
if task.owner == owner &&
task.action == action &&
task.severity == severity &&
task.due_day == due_day {
return Some(index)
}
}
None
}
///|
fn operator_due_day(today : Int, decision : CapsuleDecision) -> Int {
match decision.action {
DeleteData => today
QuarantineData => today
BlockCollection => today
AnonymizeData => today
KeepData => today + decision.days_until_delete
}
}
///|
fn task_id(
owner : String,
action : RetentionAction,
severity : RetentionSeverity,
due_day : Int,
) -> String {
owner +
"." +
action.label() +
"." +
severity.label() +
"." +
due_day.to_string()
}
///|
fn reason_kind_labels(reasons : Array[RetentionReason]) -> Array[String] {
let labels : Array[String] = []
for reason in reasons {
let label = reason.kind.label()
if !labels.contains(label) {
labels.push(label)
}
}
sort_strings(labels)
}
///|
fn checklist_for(action : RetentionAction) -> Array[String] {
match action {
KeepData => ["No operator work is required."]
AnonymizeData =>
[
"Run the anonymization pipeline for listed capsules.", "Verify direct identifiers are removed or aggregated.",
"Record the anonymization batch id in the application log.",
]
DeleteData =>
[
"Delete listed capsules from active storage.", "Remove scheduled exports and derived caches.",
"Record deletion confirmation with the responsible owner.",
]
QuarantineData =>
[
"Move listed capsules out of normal processing.", "Fix rule, region, encryption, or sharing metadata.",
"Re-run CapsuleTrace before returning data to active use.",
]
BlockCollection =>
[
"Stop new collection for listed purpose/data-kind pairs.", "Delete any already-collected records if policy requires it.",
"Update product or consent flow before enabling collection again.",
]
}
}
///|
fn sort_operator_tasks(values : Array[OperatorTask]) -> Array[OperatorTask] {
let mut sorted : Array[OperatorTask] = []
for value in values {
let next : Array[OperatorTask] = []
let mut inserted = false
for existing in sorted {
if !inserted && compare_operator_task(value, existing) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn compare_operator_task(a : OperatorTask, b : OperatorTask) -> Int {
let severity_delta = b.severity.weight() - a.severity.weight()
if severity_delta != 0 {
severity_delta
} else if a.due_day != b.due_day {
a.due_day - b.due_day
} else {
a.id.lexical_compare(b.id)
}
}
///|
fn operator_tasks_to_json(items : Array[OperatorTask]) -> Json {
let values : Array[Json] = []
for item in items {
values.push(
Json::object(
Map([
("id", Json::string(item.id)),
("owner", Json::string(item.owner)),
("action", Json::string(item.action.label())),
("severity", Json::string(item.severity.label())),
("due_day", json_int(item.due_day)),
("capsule_ids", string_array_to_json(item.capsule_ids)),
("reason_kinds", string_array_to_json(item.reason_kinds)),
("checklist", string_array_to_json(item.checklist)),
("status", Json::string(item.status.label())),
]),
),
)
}
Json::array(values)
}
///|
fn string_array_to_json(items : Array[String]) -> Json {
let values : Array[Json] = []
for item in items {
values.push(Json::string(item))
}
Json::array(values)
}