///|
/// Safe rollout phase assigned to a generated migration step.
pub(all) enum MigrationPhase {
ExpandReceivers
MigrateProducers
MigrateStoredData
EnforceTarget
ObserveRollout
} derive(Eq, Debug)
///|
/// One actionable, verifiable migration task.
pub(all) struct MigrationStep {
id : String
phase : MigrationPhase
path : String
action : String
verification : String
related_codes : Array[String]
} derive(Eq, Debug)
///|
/// Ordered rollout plan synthesized from compatibility findings.
pub(all) struct MigrationPlan {
contract_name : String
from_version : String
to_version : String
risk : String
steps : Array[MigrationStep]
} derive(Eq, Debug)
///|
pub fn MigrationPhase::render(self : MigrationPhase) -> String {
match self {
ExpandReceivers => "1-expand-receivers"
MigrateProducers => "2-migrate-producers"
MigrateStoredData => "3-migrate-stored-data"
EnforceTarget => "4-enforce-target"
ObserveRollout => "5-observe-rollout"
}
}
///|
/// Build an ordered plan from breaking findings. Compatible reports still get
/// a final observation step so releases have an explicit verification point.
pub fn plan_migration(report : AnalysisReport) -> MigrationPlan {
let steps : Array[MigrationStep] = []
for change in report.changes {
if change.severity != Breaking {
continue
}
append_change_plan(change, steps)
}
steps.sort_by((left, right) => {
let phase_order = phase_rank(left.phase) - phase_rank(right.phase)
if phase_order != 0 {
phase_order
} else {
let path_order = left.path.compare(right.path)
if path_order != 0 {
path_order
} else {
left.id.compare(right.id)
}
}
})
append_unique_step(steps, {
id: "observe-release",
phase: ObserveRollout,
path: "$",
action: "Observe compatibility error rates through one complete rollout window.",
verification: "No target-side rejection is attributable to the contract change.",
related_codes: [],
})
let risk = migration_risk(report)
{
contract_name: report.contract_name,
from_version: report.old_version,
to_version: report.new_version,
risk,
steps,
}
}
///|
fn append_change_plan(change : Change, steps : Array[MigrationStep]) -> Unit {
match change.code {
"REQUIRED_FIELD_ADDED" | "FIELD_BECAME_REQUIRED" => {
append_step(
steps,
change,
"accept-missing-" + slug(change.path),
ExpandReceivers,
"Deploy a receiver version that accepts the field as optional.",
"The receiver accepts the omission witness.",
)
append_step(
steps,
change,
"populate-" + slug(change.path),
MigrateProducers,
"Update every producer to populate the field on all new payloads.",
"Producer telemetry shows the field on 100% of emitted payloads.",
)
append_step(
steps,
change,
"require-" + slug(change.path),
EnforceTarget,
"Make the field required only after producer convergence.",
"EvoWitness no longer reports the required-field break.",
)
}
"FIELD_REMOVED" => {
append_step(
steps,
change,
"deprecate-" + slug(change.path),
ExpandReceivers,
"Keep accepting the field while marking it deprecated.",
"Old and new receivers both accept payloads containing the field.",
)
append_step(
steps,
change,
"stop-emitting-" + slug(change.path),
MigrateProducers,
"Stop emitting the deprecated field from every producer.",
"No observed payload contains the deprecated field.",
)
append_step(
steps,
change,
"remove-" + slug(change.path),
EnforceTarget,
"Remove the field after the compatibility window expires.",
"Forward analysis has no FIELD_REMOVED result.",
)
}
"ENUM_VALUE_REMOVED" | "FIELD_TYPE_NARROWED" => {
append_step(
steps,
change,
"retain-wide-type-" + slug(change.path),
ExpandReceivers,
"Retain the source value set in receivers during migration.",
"The receiver accepts the generated type witness.",
)
append_step(
steps,
change,
"rewrite-type-values-" + slug(change.path),
MigrateProducers,
"Move producers to values accepted by both source and target types.",
"No producer emits a value outside the target set.",
)
append_step(
steps,
change,
"backfill-type-values-" + slug(change.path),
MigrateStoredData,
"Rewrite or quarantine stored values excluded by the target type.",
"A validation scan reports no excluded stored value.",
)
append_step(
steps,
change,
"narrow-type-" + slug(change.path),
EnforceTarget,
"Apply the narrower target type after producers and stored data converge.",
"The generated witness is absent from production and migration fixtures.",
)
}
"MIN_INCREASED"
| "MAX_DECREASED"
| "MIN_LENGTH_INCREASED"
| "MAX_LENGTH_DECREASED" => {
append_step(
steps,
change,
"retain-bound-" + slug(change.path),
ExpandReceivers,
"Keep the source bound active while rollout is mixed-version.",
"The receiver accepts the boundary witness.",
)
append_step(
steps,
change,
"constrain-producers-" + slug(change.path),
MigrateProducers,
"Update producers to emit values inside the target bound.",
"Boundary metrics show no newly emitted out-of-range values.",
)
append_step(
steps,
change,
"backfill-bound-" + slug(change.path),
MigrateStoredData,
"Repair or quarantine stored values outside the target bound.",
"A validation scan passes against the target constraint.",
)
append_step(
steps,
change,
"enforce-bound-" + slug(change.path),
EnforceTarget,
"Enforce the target bound after data convergence.",
"Compatibility analysis and target validation pass for rollout fixtures.",
)
}
"OBJECT_CLOSED" => {
append_step(
steps,
change,
"inventory-extra-fields-" + slug(change.path),
ExpandReceivers,
"Inventory additional fields before closing the object.",
"Observed extra-field names have documented owners and disposition.",
)
append_step(
steps,
change,
"remove-extra-fields-" + slug(change.path),
MigrateProducers,
"Stop emitting undeclared fields or add them to the target contract.",
"No payload triggers UNKNOWN_FIELD in shadow validation.",
)
append_step(
steps,
change,
"close-object-" + slug(change.path),
EnforceTarget,
"Change the object to closed after the extra-field window is clean.",
"The '__extra' witness is the only intentionally rejected probe.",
)
}
"TYPE_REMOVED" => {
append_step(
steps,
change,
"retain-type-" + slug(change.path),
ExpandReceivers,
"Retain the type declaration while any producer or stored record uses it.",
"Both contract versions accept the type's minimal payload.",
)
append_step(
steps,
change,
"migrate-type-users-" + slug(change.path),
MigrateProducers,
"Move all producers and consumers to the replacement type.",
"Usage telemetry for the removed type reaches zero.",
)
append_step(
steps,
change,
"remove-type-" + slug(change.path),
EnforceTarget,
"Remove the type after the retention window.",
"No TYPE_REMOVED finding remains in the supported version matrix.",
)
}
"CONTRACT_RENAMED" => {
append_step(
steps,
change,
"dual-publish-contract",
ExpandReceivers,
"Publish the new name while retaining an alias for the old contract name.",
"Both names resolve to equivalent accepted-value sets.",
)
append_step(
steps,
change,
"migrate-contract-name",
MigrateProducers,
"Move producers and consumers to the new contract name.",
"Old-name usage reaches zero across the support window.",
)
append_step(
steps,
change,
"retire-contract-alias",
EnforceTarget,
"Retire the old alias after downstream convergence.",
"Supported clients no longer request the old name.",
)
}
_ =>
append_step(
steps,
change,
"resolve-" + slug(change.path) + "-" + change.code.to_lower(),
MigrateProducers,
change.hint,
"The related breaking finding is absent from a fresh analysis.",
)
}
}
///|
fn append_step(
steps : Array[MigrationStep],
change : Change,
id : String,
phase : MigrationPhase,
action : String,
verification : String,
) -> Unit {
append_unique_step(steps, {
id,
phase,
path: change.path,
action,
verification,
related_codes: [change.code],
})
}
///|
fn append_unique_step(
steps : Array[MigrationStep],
candidate : MigrationStep,
) -> Unit {
match steps.search_by(step => step.id == candidate.id) {
Some(index) => {
let existing = steps[index]
let codes = existing.related_codes.copy()
for code in candidate.related_codes {
if !codes.contains(code) {
codes.push(code)
}
}
steps[index] = {
id: existing.id,
phase: existing.phase,
path: existing.path,
action: existing.action,
verification: existing.verification,
related_codes: codes,
}
}
None => steps.push(candidate)
}
}
///|
fn phase_rank(phase : MigrationPhase) -> Int {
match phase {
ExpandReceivers => 1
MigrateProducers => 2
MigrateStoredData => 3
EnforceTarget => 4
ObserveRollout => 5
}
}
///|
fn migration_risk(report : AnalysisReport) -> String {
let count = report.breaking_count()
if count == 0 {
"low"
} else if count <= 2 {
"medium"
} else if count <= 5 {
"high"
} else {
"critical"
}
}
///|
fn slug(value : String) -> String {
let out = StringBuilder()
let mut previous_dash = false
for char in value.to_lower() {
if char.is_ascii_alphabetic() || char.is_ascii_digit() {
out.write_char(char)
previous_dash = false
} else if !previous_dash && !out.is_empty() {
out.write_char('-')
previous_dash = true
}
}
let result = out.to_string()
if result.has_suffix("-") {
result[:result.length() - 1].to_owned()
} else {
result
}
}
///|
pub fn MigrationPlan::to_markdown(self : MigrationPlan) -> String {
let out = StringBuilder()
out.write_string("# Migration plan for " + self.contract_name + "\n\n")
out.write_string(
"- Versions: `" + self.from_version + "` → `" + self.to_version + "`\n",
)
out.write_string("- Risk: **" + self.risk.to_upper() + "**\n")
out.write_string("- Steps: " + self.steps.length().to_string() + "\n\n")
let mut current_phase = ""
for step in self.steps {
let phase = step.phase.render()
if phase != current_phase {
out.write_string("## " + phase + "\n\n")
current_phase = phase
}
out.write_string("- [ ] **" + step.id + "** (`" + step.path + "`): ")
out.write_string(step.action + "\n")
out.write_string(" - Verify: " + step.verification + "\n")
if !step.related_codes.is_empty() {
out.write_string(
" - Findings: `" + step.related_codes.join("`, `") + "`\n",
)
}
}
out.to_string()
}