use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use super::planner::{ContextConfig, PlanResult};
use super::{validate_against, Plan, Primitive, PrimitiveError};
use crate::schema::{Schema, SchemaModel};
pub const PLAN_DOCUMENT_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl RiskLevel {
pub fn as_str(self) -> &'static str {
match self {
RiskLevel::Low => "Low",
RiskLevel::Medium => "Medium",
RiskLevel::High => "High",
RiskLevel::Critical => "Critical",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanImpact {
pub adds_fields: usize,
pub removes_fields: usize,
pub renames: usize,
pub type_changes: usize,
pub nullability_changes: usize,
pub touches_core_models: bool,
pub destructive: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanDocument {
pub version: u32,
pub created_at: String,
pub prompt: String,
pub explanation: String,
pub risk: RiskLevel,
pub impact: PlanImpact,
pub plan: Plan,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LoadedPlan {
Document(PlanDocument),
RawPlan(Plan),
}
impl LoadedPlan {
pub fn plan(&self) -> &Plan {
match self {
LoadedPlan::Document(d) => &d.plan,
LoadedPlan::RawPlan(p) => p,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlanReview {
pub plan: Plan,
pub impact: PlanImpact,
pub risk: RiskLevel,
pub warnings: Vec<String>,
pub validation: ValidationOutcome,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ValidationOutcome {
Valid,
Invalid { step: usize, reason: PrimitiveError },
}
impl ValidationOutcome {
pub fn is_valid(&self) -> bool {
matches!(self, ValidationOutcome::Valid)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum ReviewError {
Parse(String),
UnknownVersion { found: u32, expected: u32 },
InvalidPlan(PrimitiveError),
}
impl std::fmt::Display for ReviewError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(msg) => write!(f, "plan review: parse error: {msg}"),
Self::UnknownVersion { found, expected } => write!(
f,
"plan review: unsupported document version {found} (this build reads version {expected})"
),
Self::InvalidPlan(e) => write!(f, "plan review: invalid plan: {e}"),
}
}
}
impl std::error::Error for ReviewError {}
pub fn build_plan_document(
schema: &Schema,
prompt: &str,
result: &PlanResult,
context: Option<&ContextConfig>,
) -> Result<PlanDocument, ReviewError> {
build_plan_document_with_timestamp(schema, prompt, result, Utc::now(), context)
}
pub fn build_plan_document_with_timestamp(
schema: &Schema,
prompt: &str,
result: &PlanResult,
timestamp: DateTime<Utc>,
context: Option<&ContextConfig>,
) -> Result<PlanDocument, ReviewError> {
result
.plan
.validate(schema)
.map_err(ReviewError::InvalidPlan)?;
let impact = compute_impact(&result.plan, schema);
let risk = classify_risk(&result.plan, &impact, &ValidationOutcome::Valid, context);
Ok(PlanDocument {
version: PLAN_DOCUMENT_VERSION,
created_at: timestamp.to_rfc3339_opts(SecondsFormat::Secs, true),
prompt: prompt.to_string(),
explanation: result.explanation.clone(),
risk,
impact,
plan: result.plan.clone(),
})
}
pub fn review_plan(
schema: &Schema,
plan: &Plan,
context: Option<&ContextConfig>,
) -> Result<PlanReview, ReviewError> {
let validation = match simulate_plan(plan, schema) {
Ok(()) => ValidationOutcome::Valid,
Err((step, reason)) => ValidationOutcome::Invalid { step, reason },
};
let impact = compute_impact(plan, schema);
let risk = classify_risk(plan, &impact, &validation, context);
let mut warnings = warnings_for(plan, context);
warnings.extend(relation_warnings_for(plan, schema, context));
Ok(PlanReview {
plan: plan.clone(),
impact,
risk,
warnings,
validation,
})
}
fn relation_warnings_for(
plan: &Plan,
schema: &Schema,
context: Option<&ContextConfig>,
) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let pii: Vec<&str> = context.map(|c| c.pii_fields()).unwrap_or_default();
for step in &plan.steps {
let Primitive::AddRelation(r) = step else {
continue;
};
out.push(format!(
"Relation `{}.{}` → `{}` is recorded without a SQL foreign-key constraint in 0.8.x. Orphan rows are possible if the target is deleted — referential integrity lands in 0.9.0.",
r.from, r.via, r.to,
));
if !pii.is_empty() {
if let Some(target) = schema.models.iter().find(|m| m.name == r.to) {
let pii_hits: Vec<&str> = target
.fields
.iter()
.filter_map(|f| pii.iter().copied().find(|p| *p == f.name))
.collect();
if !pii_hits.is_empty() {
out.push(format!(
"Linking `{}` to `{}` creates a path to personally-identifying fields on the target ({}). Review GDPR minimisation / purpose-limitation before applying.",
r.from,
r.to,
pii_hits.join(", "),
));
}
}
}
}
out
}
pub fn load_plan(json: &str) -> Result<LoadedPlan, ReviewError> {
if let Ok(doc) = serde_json::from_str::<PlanDocument>(json) {
if doc.version != PLAN_DOCUMENT_VERSION {
return Err(ReviewError::UnknownVersion {
found: doc.version,
expected: PLAN_DOCUMENT_VERSION,
});
}
return Ok(LoadedPlan::Document(doc));
}
match serde_json::from_str::<Plan>(json) {
Ok(plan) => Ok(LoadedPlan::RawPlan(plan)),
Err(e) => Err(ReviewError::Parse(e.to_string())),
}
}
pub fn compute_impact(plan: &Plan, schema: &Schema) -> PlanImpact {
let mut out = PlanImpact::default();
for step in &plan.steps {
match step {
Primitive::AddField(_) => out.adds_fields += 1,
Primitive::RemoveField(_) => {
out.removes_fields += 1;
out.destructive = true;
}
Primitive::RenameField(_) | Primitive::RenameModel(_) => out.renames += 1,
Primitive::ChangeFieldType(_) => out.type_changes += 1,
Primitive::ChangeFieldNullability(_) => out.nullability_changes += 1,
Primitive::RemoveModel(_) | Primitive::RemoveRelation(_) => {
out.destructive = true;
}
_ => {}
}
if touches_core_model(step, schema) {
out.touches_core_models = true;
}
}
out
}
pub fn classify_risk(
plan: &Plan,
impact: &PlanImpact,
validation: &ValidationOutcome,
context: Option<&ContextConfig>,
) -> RiskLevel {
if !validation.is_valid() {
return RiskLevel::Critical;
}
if impact.touches_core_models {
return RiskLevel::Critical;
}
if plan.steps.iter().any(|s| s.is_developer_only()) {
return RiskLevel::Critical;
}
if let Some(ctx) = context {
let pii = ctx.pii_fields();
for step in &plan.steps {
match step {
Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => {
return RiskLevel::Critical;
}
Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => {
return RiskLevel::Critical;
}
Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => {
return RiskLevel::Critical;
}
_ => {}
}
}
}
let mut max = RiskLevel::Low;
for step in &plan.steps {
let r = per_step_risk(step);
if r > max {
max = r;
}
}
let mixes_add_and_remove = impact.adds_fields > 0 && impact.removes_fields > 0;
if mixes_add_and_remove && max < RiskLevel::High {
max = RiskLevel::High;
}
max
}
pub fn warnings_for(plan: &Plan, context: Option<&ContextConfig>) -> Vec<String> {
use crate::ai::OnDelete;
let mut out: Vec<String> = Vec::new();
let mut has_remove = false;
let mut has_rename_model = false;
let mut has_rename_field = false;
let mut has_type_change = false;
let mut has_require = false;
let mut has_remove_model = false;
let mut has_dev_only = false;
for step in &plan.steps {
match step {
Primitive::RemoveField(_) => has_remove = true,
Primitive::RenameModel(_) => has_rename_model = true,
Primitive::RenameField(_) => has_rename_field = true,
Primitive::ChangeFieldType(_) => has_type_change = true,
Primitive::ChangeFieldNullability(c) if !c.nullable => has_require = true,
Primitive::RemoveModel(_) => has_remove_model = true,
Primitive::AddRelation(r) => {
if r.required {
out.push(format!(
"Relation `{model}.{via}` → `{to}` is required (NOT NULL FK). \
Existing rows with no matching parent will prevent the \
migration; use `rustio migrate add-fks --write` to retrofit \
via recreate-table instead of ALTER TABLE.",
model = r.from,
via = r.via,
to = r.to,
));
}
if matches!(r.on_delete, OnDelete::Cascade) {
out.push(format!(
"Relation `{model}.{via}` uses ON DELETE CASCADE: deleting a \
single `{to}` row will delete every `{model}` row that \
points at it. Review the blast radius before execution.",
model = r.from,
via = r.via,
to = r.to,
));
}
}
_ => {}
}
if step.is_developer_only() {
has_dev_only = true;
}
}
if has_remove {
out.push("This plan removes a field. Existing data in that column may become inaccessible after execution.".into());
}
if has_remove_model {
out.push("This plan removes a model. Every row, foreign-key reference, and admin route for that model will be dropped.".into());
}
if has_rename_model {
out.push("This plan renames a model. Downstream code, admin URLs, and external integrations that hard-code the old name will break.".into());
}
if has_rename_field {
out.push("This plan renames a field. Queries, serialised payloads, and UI references using the old name will break.".into());
}
if has_require {
out.push("This plan changes a field from nullable to required. Rows with a NULL in that column will fail to load after execution.".into());
}
if has_type_change {
out.push("This plan changes a field's type. The executor may refuse conversions it considers lossy.".into());
}
if has_type_change || has_require {
out.push("This operation rewrites the entire table. Large tables may cause downtime during execution.".into());
}
if plan.steps.len() > 1 {
out.push(format!(
"This plan performs {n} operations. Review each step individually.",
n = plan.steps.len(),
));
}
if has_dev_only {
out.push("This plan contains a developer-only primitive. It must never be executed from an AI pipeline.".into());
}
if let Some(ctx) = context {
let pii = ctx.pii_fields();
for step in &plan.steps {
match step {
Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => {
out.push(format!(
"Field `{}.{}` is considered sensitive personal data under the project's context{}. Removing it is irreversible — review retention obligations first.",
r.model,
r.field,
describe_context(ctx),
));
}
Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => {
out.push(format!(
"Field `{}.{}` is sensitive personal data; renaming it invalidates any existing access-log / audit trail keyed on the old name.",
r.model, r.from,
));
}
Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => {
out.push(format!(
"Field `{}.{}` is sensitive personal data; type changes may affect hashing, masking, or retention pipelines keyed on its storage shape.",
c.model, c.field,
));
}
_ => {}
}
}
if let Some(schema) = ctx.industry_schema() {
for step in &plan.steps {
if let Primitive::RemoveField(r) = step {
if schema.required_fields.iter().any(|f| f == &r.field) {
out.push(format!(
"Field `{}.{}` is a standard convention for the `{}` industry. Removing it will break downstream integrations that assume it exists.",
r.model,
r.field,
ctx.industry.as_deref().unwrap_or(""),
));
}
}
}
}
}
out
}
fn describe_context(ctx: &ContextConfig) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = &ctx.country {
parts.push(format!("country={c}"));
}
if let Some(i) = &ctx.industry {
parts.push(format!("industry={i}"));
}
if ctx.requires_gdpr() {
parts.push("GDPR".to_string());
}
if parts.is_empty() {
String::new()
} else {
format!(" ({})", parts.join(", "))
}
}
pub fn render_review_human(review: &PlanReview, header: Option<&ReviewHeader>) -> String {
let mut out = String::new();
out.push_str("Plan review\n");
if let Some(h) = header {
if let Some(p) = &h.prompt {
out.push_str(&format!("\nPrompt:\n {p}\n"));
}
if let Some(e) = &h.explanation {
out.push_str(&format!("\nExplanation:\n {e}\n"));
}
if let Some(src) = &h.source {
out.push_str(&format!("\nSource:\n {src}\n"));
}
}
out.push_str(&format!("\nRisk:\n {}\n", review.risk.as_str()));
out.push_str("\nImpact:\n");
for line in render_impact_lines(&review.impact) {
out.push_str(" - ");
out.push_str(&line);
out.push('\n');
}
out.push_str("\nPlanned changes:\n");
if review.plan.steps.is_empty() {
out.push_str(" - (none)\n");
} else {
for step in &review.plan.steps {
out.push_str(" - ");
out.push_str(&summarise_primitive(step));
out.push('\n');
}
}
out.push_str("\nValidation:\n");
match &review.validation {
ValidationOutcome::Valid => out.push_str(" - Passes against the current schema.\n"),
ValidationOutcome::Invalid { step, reason } => {
out.push_str(&format!(
" - FAILS at step {step}: {reason}\n",
step = step,
reason = reason,
));
out.push_str(" - The plan is stale or invalid for the current schema. Regenerate it before executing.\n");
}
}
out.push_str("\nWarnings:\n");
if review.warnings.is_empty() {
out.push_str(" - None\n");
} else {
for w in &review.warnings {
out.push_str(" - ");
out.push_str(w);
out.push('\n');
}
}
out
}
#[derive(Debug, Default, Clone)]
pub struct ReviewHeader {
pub prompt: Option<String>,
pub explanation: Option<String>,
pub source: Option<String>,
}
pub fn render_plan_document_json(doc: &PlanDocument) -> Result<String, ReviewError> {
let mut out =
serde_json::to_string_pretty(doc).map_err(|e| ReviewError::Parse(e.to_string()))?;
out.push('\n');
Ok(out)
}
fn simulate_plan(plan: &Plan, schema: &Schema) -> Result<(), (usize, PrimitiveError)> {
let mut state = schema.clone();
for (idx, step) in plan.steps.iter().enumerate() {
if step.is_developer_only() {
return Err((
idx,
PrimitiveError::DeveloperOnlyNotAllowedInPlan { op: step.op_name() },
));
}
if let Err(e) = super::validate_primitive(step) {
return Err((idx, e));
}
if let Err(e) = validate_against(step, &state) {
return Err((idx, e));
}
apply_shadow_for_review(step, &mut state);
}
Ok(())
}
fn apply_shadow_for_review(p: &Primitive, schema: &mut Schema) {
use crate::schema::{SchemaField, SchemaRelation};
match p {
Primitive::AddModel(m) => {
let mut fields: Vec<SchemaField> = m
.fields
.iter()
.map(|f| SchemaField {
name: f.name.clone(),
ty: f.ty.clone(),
nullable: f.nullable,
editable: f.editable,
relation: None,
})
.collect();
fields.sort_by(|a, b| a.name.cmp(&b.name));
schema.models.push(SchemaModel {
name: m.name.clone(),
table: m.table.clone(),
admin_name: m.table.clone(),
display_name: m.name.clone(),
singular_name: m.name.clone(),
fields,
relations: Vec::new(),
core: false,
});
schema.models.sort_by(|a, b| a.name.cmp(&b.name));
}
Primitive::RemoveModel(m) => schema.models.retain(|x| x.name != m.name),
Primitive::AddField(af) => {
if let Some(model) = schema.models.iter_mut().find(|m| m.name == af.model) {
model.fields.push(SchemaField {
name: af.field.name.clone(),
ty: af.field.ty.clone(),
nullable: af.field.nullable,
editable: af.field.editable,
relation: None,
});
model.fields.sort_by(|a, b| a.name.cmp(&b.name));
}
}
Primitive::RemoveField(rf) => {
if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
model.fields.retain(|f| f.name != rf.field);
}
}
Primitive::RenameModel(rm) => {
if let Some(model) = schema.models.iter_mut().find(|m| m.name == rm.from) {
model.name = rm.to.clone();
model.singular_name = rm.to.clone();
}
schema.models.sort_by(|a, b| a.name.cmp(&b.name));
}
Primitive::RenameField(rf) => {
if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
if let Some(field) = model.fields.iter_mut().find(|f| f.name == rf.from) {
field.name = rf.to.clone();
}
model.fields.sort_by(|a, b| a.name.cmp(&b.name));
}
}
Primitive::ChangeFieldType(c) => {
if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
field.ty = c.new_type.clone();
}
}
}
Primitive::ChangeFieldNullability(c) => {
if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
field.nullable = c.nullable;
}
}
}
Primitive::AddRelation(r) => {
use crate::schema::{Relation, RelationKind};
if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
model.relations.push(SchemaRelation {
kind: format!("{:?}", r.kind).to_lowercase(),
to: r.to.clone(),
via: r.via.clone(),
});
if matches!(r.kind, RelationKind::BelongsTo)
&& !model.fields.iter().any(|f| f.name == r.via)
{
model.fields.push(SchemaField {
name: r.via.clone(),
ty: "i64".to_string(),
nullable: !r.required,
editable: true,
relation: Some(Relation {
model: r.to.clone(),
field: "id".to_string(),
kind: RelationKind::BelongsTo,
display_field: None,
required: Some(r.required),
on_delete: Some(r.on_delete.as_str().to_string()),
}),
});
model.fields.sort_by(|a, b| a.name.cmp(&b.name));
}
}
}
Primitive::RemoveRelation(r) => {
if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
model.relations.retain(|rel| rel.via != r.via);
}
}
Primitive::UpdateAdmin(_) | Primitive::CreateMigration(_) => {}
}
}
fn touches_core_model(p: &Primitive, schema: &Schema) -> bool {
let target = match p {
Primitive::AddField(a) => Some(a.model.as_str()),
Primitive::RemoveField(r) => Some(r.model.as_str()),
Primitive::RenameField(r) => Some(r.model.as_str()),
Primitive::ChangeFieldType(c) => Some(c.model.as_str()),
Primitive::ChangeFieldNullability(c) => Some(c.model.as_str()),
Primitive::UpdateAdmin(u) => Some(u.model.as_str()),
Primitive::RenameModel(r) => Some(r.from.as_str()),
Primitive::RemoveModel(m) => Some(m.name.as_str()),
Primitive::AddRelation(r) => Some(r.from.as_str()),
Primitive::RemoveRelation(r) => Some(r.from.as_str()),
Primitive::AddModel(_) | Primitive::CreateMigration(_) => None,
};
let Some(name) = target else { return false };
schema.models.iter().any(|m| m.name == name && m.core)
}
fn per_step_risk(p: &Primitive) -> RiskLevel {
use crate::ai::OnDelete;
match p {
Primitive::AddField(a) if a.field.nullable => RiskLevel::Low,
Primitive::AddField(_) => RiskLevel::Low,
Primitive::AddRelation(r) => match (r.required, r.on_delete) {
(true, OnDelete::Cascade) => RiskLevel::High,
(true, _) | (_, OnDelete::Cascade) => RiskLevel::Medium,
_ => RiskLevel::Low,
},
Primitive::AddModel(_) => RiskLevel::Low,
Primitive::UpdateAdmin(_) => RiskLevel::Low,
Primitive::ChangeFieldNullability(c) if c.nullable => RiskLevel::Low,
Primitive::ChangeFieldNullability(_) => RiskLevel::High,
Primitive::RenameField(_) | Primitive::RenameModel(_) | Primitive::ChangeFieldType(_) => {
RiskLevel::Medium
}
Primitive::RemoveField(_) | Primitive::RemoveModel(_) | Primitive::RemoveRelation(_) => {
RiskLevel::High
}
Primitive::CreateMigration(_) => RiskLevel::Critical,
}
}
fn summarise_primitive(p: &Primitive) -> String {
match p {
Primitive::AddField(a) => format!(
"Add field \"{}\" ({}{}) to model \"{}\"",
a.field.name,
a.field.ty,
if a.field.nullable { ", nullable" } else { "" },
a.model,
),
Primitive::RemoveField(r) => {
format!("Remove field \"{}\" from model \"{}\"", r.field, r.model)
}
Primitive::RenameField(r) => {
format!("Rename field \"{}.{}\" to \"{}\"", r.model, r.from, r.to)
}
Primitive::RenameModel(r) => {
format!("Rename model \"{}\" to \"{}\"", r.from, r.to)
}
Primitive::ChangeFieldType(c) => format!(
"Change type of \"{}.{}\" to {}",
c.model, c.field, c.new_type
),
Primitive::ChangeFieldNullability(c) => format!(
"Mark \"{}.{}\" as {}",
c.model,
c.field,
if c.nullable { "nullable" } else { "required" },
),
Primitive::AddModel(m) => format!(
"Add model \"{}\" ({} field{})",
m.name,
m.fields.len(),
if m.fields.len() == 1 { "" } else { "s" }
),
Primitive::RemoveModel(m) => format!("Remove model \"{}\"", m.name),
Primitive::AddRelation(r) => format!(
"Add relation {:?}: {}.{} -> {}",
r.kind, r.from, r.via, r.to
),
Primitive::RemoveRelation(r) => {
format!("Remove relation \"{}.{}\"", r.from, r.via)
}
Primitive::UpdateAdmin(u) => format!(
"Update admin attribute \"{}.{}\".{}",
u.model, u.field, u.attr
),
Primitive::CreateMigration(m) => format!("[dev-only] create_migration \"{}\"", m.name),
}
}
fn render_impact_lines(i: &PlanImpact) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
push_count_line(&mut lines, "Add", i.adds_fields, "field");
push_count_line(&mut lines, "Remove", i.removes_fields, "field");
push_count_line(&mut lines, "Rename", i.renames, "item");
push_count_line(&mut lines, "Type change", i.type_changes, "field");
push_count_line(
&mut lines,
"Nullability change",
i.nullability_changes,
"field",
);
if i.destructive {
lines.push("Includes at least one destructive step".into());
} else {
lines.push("No destructive changes".into());
}
if i.touches_core_models {
lines.push("Touches a core model — review carefully".into());
} else {
lines.push("Does not touch core models".into());
}
lines
}
fn push_count_line(out: &mut Vec<String>, verb: &str, n: usize, unit: &str) {
if n == 0 {
return;
}
out.push(format!(
"{verb} {n} {unit}{s}",
s = if n == 1 { "" } else { "s" }
));
}