///|
pub(all) enum RunbookRecordKind {
OwnerNote
MetricNote
RollbackNote
ApprovalNote
IncidentNote
ReleaseNote
SupportNote
} derive(Eq, Debug)
///|
pub(all) enum RunbookState {
Draft
Ready
Accepted
NeedsFollowup
} derive(Eq, Debug)
///|
pub(all) struct RunbookRecord {
id : String
kind : RunbookRecordKind
state : RunbookState
title : String
evidence : String
owner : String
minute : Int
action : String
} derive(Eq, Debug)
///|
pub fn RunbookRecord::RunbookRecord(
id? : String = "",
kind? : RunbookRecordKind = OwnerNote,
state? : RunbookState = Ready,
title? : String = "",
evidence? : String = "",
owner? : String = "",
minute? : Int = 0,
action? : String = "",
) -> RunbookRecord {
{ id, kind, state, title, evidence, owner, minute, action }
}
///|
pub(all) struct RunbookReport {
score : Int
verdict : String
records : Array[RunbookRecord]
findings : Array[RolloutFinding]
owner_count : Int
metric_count : Int
rollback_count : Int
approval_count : Int
release_count : Int
} derive(Eq, Debug)
///|
pub fn runbook_review(records : Array[RunbookRecord]) -> RunbookReport {
let findings = Array::new(capacity=12)
push_runbook_kind(
findings,
records,
OwnerNote,
"runbook.owner",
"Owner evidence is retained",
)
push_runbook_kind(
findings,
records,
MetricNote,
"runbook.metrics",
"Metric evidence is retained",
)
push_runbook_kind(
findings,
records,
RollbackNote,
"runbook.rollback",
"Rollback evidence is retained",
)
push_runbook_kind(
findings,
records,
ApprovalNote,
"runbook.approval",
"Approval evidence is retained",
)
push_runbook_kind(
findings,
records,
ReleaseNote,
"runbook.release",
"Release evidence is retained",
)
push_runbook_evidence(findings, records)
push_runbook_followup(findings, records)
let score = runbook_score(findings)
{
score,
verdict: runbook_verdict(findings, score),
records,
findings,
owner_count: count_runbook_kind(records, OwnerNote),
metric_count: count_runbook_kind(records, MetricNote),
rollback_count: count_runbook_kind(records, RollbackNote),
approval_count: count_runbook_kind(records, ApprovalNote),
release_count: count_runbook_kind(records, ReleaseNote),
}
}
///|
fn push_runbook_kind(
findings : Array[RolloutFinding],
records : Array[RunbookRecord],
kind : RunbookRecordKind,
id : String,
title : String,
) -> Unit {
let count = count_runbook_kind(records, kind)
if count > 0 {
findings.push(
finding_pass(id, PlanShape, title, count.to_string() + " records"),
)
} else {
findings.push(
finding_warn(
id,
PlanShape,
title + " missing",
"0 records",
"Add one " + runbook_kind_text(kind) + " record to the rollout runbook.",
penalty=6,
),
)
}
}
///|
fn push_runbook_evidence(
findings : Array[RolloutFinding],
records : Array[RunbookRecord],
) -> Unit {
let mut missing = 0
for record in records {
if record.state == Ready || record.state == Accepted {
if !has_text(record.evidence) && !has_text(record.action) {
missing += 1
}
}
}
if missing == 0 {
findings.push(
finding_pass(
"runbook.evidence",
PlanShape,
"Ready records have evidence",
"all ready records",
),
)
} else {
findings.push(
finding_warn(
"runbook.evidence",
PlanShape,
"Some ready records lack evidence",
missing.to_string() + " records",
"Attach metric windows, release links or operator actions to ready records.",
penalty=missing * 2,
),
)
}
}
///|
fn push_runbook_followup(
findings : Array[RolloutFinding],
records : Array[RunbookRecord],
) -> Unit {
let mut followups = 0
for record in records {
if record.state == NeedsFollowup {
followups += 1
}
}
if followups == 0 {
findings.push(
finding_pass(
"runbook.followup",
PlanShape,
"No unresolved follow-up records",
"0 followups",
),
)
} else {
findings.push(
finding_warn(
"runbook.followup",
PlanShape,
"Follow-up records remain",
followups.to_string() + " records",
"Make sure follow-ups describe future maintenance rather than unfinished launch requirements.",
penalty=followups * 4,
),
)
}
}
///|
fn count_runbook_kind(
records : Array[RunbookRecord],
kind : RunbookRecordKind,
) -> Int {
let mut total = 0
for record in records {
if record.kind == kind {
total += 1
}
}
total
}
///|
fn runbook_score(findings : Array[RolloutFinding]) -> Int {
let mut score = 100
for finding in findings {
score -= finding.penalty
}
clamp(score, 0, 100)
}
///|
fn runbook_verdict(findings : Array[RolloutFinding], score : Int) -> String {
let mut failures = 0
let mut warnings = 0
for finding in findings {
if finding.severity == Fail {
failures += 1
} else if finding.severity == Warn {
warnings += 1
}
}
if failures > 0 {
"blocked"
} else if score >= 95 && warnings == 0 {
"runbook-ready"
} else if score >= 80 {
"runbook-ready-with-notes"
} else {
"runbook-needs-work"
}
}
///|
pub fn RunbookReport::to_markdown(self : RunbookReport) -> String {
let out = StringBuilder()
out.write_string("# CanaryRing Runbook Review\n\n")
out.write_string("- Verdict: " + self.verdict + "\n")
out.write_string("- Score: " + self.score.to_string() + "/100\n")
out.write_string("- Owner records: " + self.owner_count.to_string() + "\n")
out.write_string("- Metric records: " + self.metric_count.to_string() + "\n")
out.write_string(
"- Rollback records: " + self.rollback_count.to_string() + "\n",
)
out.write_string(
"- Approval records: " + self.approval_count.to_string() + "\n",
)
out.write_string(
"- Release records: " + self.release_count.to_string() + "\n\n",
)
out.write_string("## Findings\n\n")
for finding in self.findings {
out.write_string("- [" + severity_text(finding.severity) + "] ")
out.write_string(finding.id + ": " + finding.title)
if finding.evidence != "" {
out.write_string(" -- " + finding.evidence)
}
out.write_string("\n")
}
out.write_string("\n## Records\n\n")
for record in self.records {
out.write_string("- " + record.id + " ")
out.write_string("(" + runbook_kind_text(record.kind) + ", ")
out.write_string(runbook_state_text(record.state) + "): ")
out.write_string(record.title)
if record.evidence != "" {
out.write_string(" -- " + record.evidence)
}
out.write_string("\n")
}
out.to_string()
}
///|
fn runbook_kind_text(kind : RunbookRecordKind) -> String {
match kind {
OwnerNote => "owner"
MetricNote => "metric"
RollbackNote => "rollback"
ApprovalNote => "approval"
IncidentNote => "incident"
ReleaseNote => "release"
SupportNote => "support"
}
}
///|
fn runbook_state_text(state : RunbookState) -> String {
match state {
Draft => "draft"
Ready => "ready"
Accepted => "accepted"
NeedsFollowup => "needs-followup"
}
}
///|
pub fn example_runbook_records() -> Array[RunbookRecord] {
[
RunbookRecord(
id="rb-001",
kind=OwnerNote,
state=Accepted,
title="Owner is EJJ-ai-nb",
evidence="moon.mod and repository owner",
owner="EJJ-ai-nb",
minute=0,
),
RunbookRecord(
id="rb-002",
kind=MetricNote,
state=Accepted,
title="Healthy metric windows retained",
evidence="healthy_metrics fixture and CI output",
owner="EJJ-ai-nb",
minute=60,
),
RunbookRecord(
id="rb-003",
kind=RollbackNote,
state=Ready,
title="Rollback decision uses gate failure",
evidence="evaluate_gate returns Rollback on hard metric failure",
owner="EJJ-ai-nb",
minute=120,
),
RunbookRecord(
id="rb-004",
kind=ApprovalNote,
state=Ready,
title="Manual approval required for 50% live phase",
evidence="live-50 phase has require_manual_approval=true",
owner="EJJ-ai-nb",
minute=180,
),
RunbookRecord(
id="rb-005",
kind=ReleaseNote,
state=Ready,
title="Release evidence points to canaryring",
evidence="EJJ-ai-nb/canaryring",
owner="EJJ-ai-nb",
minute=240,
),
RunbookRecord(
id="rb-006",
kind=SupportNote,
state=Ready,
title="Support handoff keeps rollout on hold if gate warns",
evidence="GateDecision Hold blocks auto-advance",
owner="EJJ-ai-nb",
minute=240,
),
]
}