Skip to main content

rustio_core/ai/
review.rs

1//! Plan Review Layer — 0.5.1.
2//!
3//! The reviewable, risk-scored boundary between the AI planner (0.5.0)
4//! and the (future) executor. Its single responsibility is to let a
5//! human operator answer the question:
6//!
7//! > "I understand exactly what the AI wants to do, how risky it is,
8//! >  and whether I should allow it."
9//!
10//! Nothing in this module touches the filesystem, the database, the
11//! schema on disk, or emits SQL. It inspects an in-memory [`Plan`]
12//! against an in-memory [`Schema`] and returns a structured report.
13//!
14//! ## What the layer provides
15//!
16//! - [`PlanDocument`] — a reviewable, serialisable envelope around a
17//!   [`Plan`], carrying the prompt, the explanation the planner gave,
18//!   the computed risk + impact, and a timestamp. Versioned (see
19//!   [`PLAN_DOCUMENT_VERSION`]) so older-format documents are rejected
20//!   rather than silently misread.
21//! - [`review_plan`] — takes a plan and the current schema and
22//!   produces a [`PlanReview`]: validation outcome, risk level, impact
23//!   counts, and a deterministic list of warnings.
24//! - [`load_plan`] — accepts either a full [`PlanDocument`] or a raw
25//!   [`Plan`] JSON and tells the caller which it read, so CLI tools
26//!   can normalise both shapes without guessing.
27//! - Renderers for both a stable JSON output and an operator-friendly
28//!   human summary.
29//!
30//! ## Determinism
31//!
32//! Risk classification, impact counting, and warning generation are
33//! **deterministic**: the same `(Plan, Schema)` always yields the
34//! same review. The only non-deterministic field anywhere in the
35//! layer is [`PlanDocument::created_at`]; for tests, use
36//! [`build_plan_document_with_timestamp`] to pin it.
37//!
38//! ## Safety posture
39//!
40//! Risk classification is *conservative*. When in doubt the layer
41//! bumps up, never down. `Critical` is reserved for situations a
42//! reviewer must refuse by default: plans that touch core models,
43//! plans that fail validation, plans containing developer-only ops.
44//!
45//! ## What this module does NOT do
46//!
47//! - It does not parse user prompts (that is the planner's job).
48//! - It does not modify the plan to "fix" it.
49//! - It does not emit SQL, write files, or open databases.
50//! - It does not call external services.
51
52use chrono::{DateTime, SecondsFormat, Utc};
53use serde::{Deserialize, Serialize};
54
55use super::planner::{ContextConfig, PlanResult};
56use super::{validate_against, Plan, Primitive, PrimitiveError};
57use crate::schema::{Schema, SchemaModel};
58
59/// Version tag written into every [`PlanDocument`]. Bumped **only** on
60/// a breaking change to the document shape — adding an optional field
61/// is a minor change that the current reader handles via serde's
62/// defaults. Parsers reject any document whose `version` doesn't match
63/// this constant, so a future executor can trust the shape it reads.
64pub const PLAN_DOCUMENT_VERSION: u32 = 1;
65
66// ---------------------------------------------------------------------------
67// Types
68// ---------------------------------------------------------------------------
69
70/// Severity class used by the review engine. Ordered so callers can
71/// compare (`risk >= RiskLevel::High`) and take the max across steps.
72///
73/// The variants are a small, deliberately-closed enum; adding one is
74/// a breaking change because reviewers rely on the four tiers.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum RiskLevel {
78    /// Reversible, non-destructive. e.g. an `AddField` for a nullable
79    /// column, or flipping a field to nullable.
80    Low,
81    /// Data-preserving but disruptive. e.g. a rename, or a type change
82    /// the executor will verify against data.
83    Medium,
84    /// Destructive or disruptive enough that a reviewer should pause.
85    /// e.g. `RemoveField`, or a plan that mixes destructive and
86    /// constructive steps.
87    High,
88    /// Must not execute without a reviewer overriding deliberately.
89    /// Reached by: touching a core model, failing validation, or
90    /// encountering a developer-only primitive in a plan.
91    Critical,
92}
93
94impl RiskLevel {
95    pub fn as_str(self) -> &'static str {
96        match self {
97            RiskLevel::Low => "Low",
98            RiskLevel::Medium => "Medium",
99            RiskLevel::High => "High",
100            RiskLevel::Critical => "Critical",
101        }
102    }
103}
104
105/// Aggregate counts of what a plan changes. Used by the CLI summary
106/// and as an input to the risk classifier. Every field is non-negative
107/// and derived mechanically from the plan — no fuzzy heuristics.
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct PlanImpact {
111    pub adds_fields: usize,
112    pub removes_fields: usize,
113    pub renames: usize,
114    pub type_changes: usize,
115    pub nullability_changes: usize,
116    /// `true` if any step's `model` points at a model flagged `core`
117    /// in the supplied schema. Core models (e.g. `User`) are
118    /// infrastructure — modifying them from an AI plan is never
119    /// acceptable without a reviewer's explicit override.
120    pub touches_core_models: bool,
121    /// `true` if the plan contains any destructive primitive
122    /// (`RemoveField`, `RemoveModel`, `RemoveRelation`). Distinct
123    /// from the per-primitive counts so consumers can branch on
124    /// "is anything destructive" without summing four fields.
125    pub destructive: bool,
126}
127
128/// Serialisable, reviewable envelope around a validated [`Plan`].
129///
130/// The document carries every piece of context a reviewer needs to
131/// decide yes/no without re-running the planner:
132/// what the user asked for, the planner's one-paragraph explanation,
133/// the computed impact + risk, and the plan itself.
134///
135/// `#[serde(deny_unknown_fields)]` is load-bearing: it prevents a
136/// future executor from silently reading a field we never meant to
137/// populate, and catches copy-paste errors in reviewer-authored
138/// documents.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct PlanDocument {
142    pub version: u32,
143    /// RFC 3339 UTC timestamp. Informational only — not used by the
144    /// review engine. Stored as a string so the format is locked down
145    /// regardless of chrono version.
146    pub created_at: String,
147    pub prompt: String,
148    pub explanation: String,
149    pub risk: RiskLevel,
150    pub impact: PlanImpact,
151    pub plan: Plan,
152}
153
154/// Output of a `load_plan` call. Lets the CLI distinguish "user
155/// handed me a raw plan" from "user handed me a reviewed document"
156/// and print a matching status line.
157#[derive(Debug, Clone, PartialEq)]
158pub enum LoadedPlan {
159    Document(PlanDocument),
160    RawPlan(Plan),
161}
162
163impl LoadedPlan {
164    pub fn plan(&self) -> &Plan {
165        match self {
166            LoadedPlan::Document(d) => &d.plan,
167            LoadedPlan::RawPlan(p) => p,
168        }
169    }
170}
171
172/// Result of [`review_plan`]. A review is always produced, even for
173/// invalid plans — callers need to *see* the invalidity reason
174/// without a separate error path.
175#[derive(Debug, Clone, PartialEq)]
176pub struct PlanReview {
177    pub plan: Plan,
178    pub impact: PlanImpact,
179    pub risk: RiskLevel,
180    pub warnings: Vec<String>,
181    pub validation: ValidationOutcome,
182}
183
184/// Did the plan survive validation against the supplied schema?
185///
186/// Invalid ≠ malformed: a plan may be structurally fine but stale
187/// (the schema it targeted has moved on). The variant carries the
188/// step index + reason so operators can pinpoint which primitive is
189/// now invalid.
190#[derive(Debug, Clone, PartialEq)]
191pub enum ValidationOutcome {
192    Valid,
193    Invalid { step: usize, reason: PrimitiveError },
194}
195
196impl ValidationOutcome {
197    pub fn is_valid(&self) -> bool {
198        matches!(self, ValidationOutcome::Valid)
199    }
200}
201
202/// Reasons a review layer operation can fail. Parse errors and
203/// version mismatches are the common cases; structural failures
204/// (e.g. `build_plan_document` handed a plan that somehow fails
205/// its own validation) are included for defence in depth.
206#[non_exhaustive]
207#[derive(Debug, Clone, PartialEq)]
208pub enum ReviewError {
209    /// JSON didn't match any known shape (document or raw plan), or
210    /// one of the shapes failed serde parsing.
211    Parse(String),
212    /// The document's `version` field didn't match
213    /// [`PLAN_DOCUMENT_VERSION`]. Loud refusal rather than silent
214    /// upgrade — the document shape is part of the API surface.
215    UnknownVersion { found: u32, expected: u32 },
216    /// A plan supplied to `build_plan_document` failed its own
217    /// internal validation. This should only happen if the planner
218    /// contract was violated upstream.
219    InvalidPlan(PrimitiveError),
220}
221
222impl std::fmt::Display for ReviewError {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        match self {
225            Self::Parse(msg) => write!(f, "plan review: parse error: {msg}"),
226            Self::UnknownVersion { found, expected } => write!(
227                f,
228                "plan review: unsupported document version {found} (this build reads version {expected})"
229            ),
230            Self::InvalidPlan(e) => write!(f, "plan review: invalid plan: {e}"),
231        }
232    }
233}
234
235impl std::error::Error for ReviewError {}
236
237// ---------------------------------------------------------------------------
238// Public API
239// ---------------------------------------------------------------------------
240
241/// Build a [`PlanDocument`] from a fresh planner result. Uses the
242/// current UTC wall-clock for `created_at`.
243///
244/// The document is validated against `schema` before being returned:
245/// an invalid plan surfaces as [`ReviewError::InvalidPlan`] rather
246/// than producing a document the reviewer might trust.
247pub fn build_plan_document(
248    schema: &Schema,
249    prompt: &str,
250    result: &PlanResult,
251    context: Option<&ContextConfig>,
252) -> Result<PlanDocument, ReviewError> {
253    build_plan_document_with_timestamp(schema, prompt, result, Utc::now(), context)
254}
255
256/// Same as [`build_plan_document`] but accepts an explicit timestamp.
257/// Tests use a pinned value so snapshot comparisons stay stable;
258/// callers with their own clock abstraction (e.g. a CI runner that
259/// freezes time) can plumb it through here.
260pub fn build_plan_document_with_timestamp(
261    schema: &Schema,
262    prompt: &str,
263    result: &PlanResult,
264    timestamp: DateTime<Utc>,
265    context: Option<&ContextConfig>,
266) -> Result<PlanDocument, ReviewError> {
267    // Defence in depth: the planner already calls Plan::validate, but
268    // an invalid plan sneaking into a saved document would be a
269    // catastrophic review-layer failure. Re-check here.
270    result
271        .plan
272        .validate(schema)
273        .map_err(ReviewError::InvalidPlan)?;
274
275    let impact = compute_impact(&result.plan, schema);
276    let risk = classify_risk(&result.plan, &impact, &ValidationOutcome::Valid, context);
277    Ok(PlanDocument {
278        version: PLAN_DOCUMENT_VERSION,
279        created_at: timestamp.to_rfc3339_opts(SecondsFormat::Secs, true),
280        prompt: prompt.to_string(),
281        explanation: result.explanation.clone(),
282        risk,
283        impact,
284        plan: result.plan.clone(),
285    })
286}
287
288/// Review a plan against a schema without executing anything. Returns
289/// the full report even if the plan is invalid — the caller decides
290/// how to present that to the user.
291///
292/// `context`, when present, escalates risk and adds context-aware
293/// warnings (e.g. removing a personnummer field under `country=SE`
294/// becomes Critical with a GDPR notice). Callers who don't have a
295/// project context pass `None` — the review then behaves exactly
296/// as in 0.5.x.
297pub fn review_plan(
298    schema: &Schema,
299    plan: &Plan,
300    context: Option<&ContextConfig>,
301) -> Result<PlanReview, ReviewError> {
302    let validation = match simulate_plan(plan, schema) {
303        Ok(()) => ValidationOutcome::Valid,
304        Err((step, reason)) => ValidationOutcome::Invalid { step, reason },
305    };
306    let impact = compute_impact(plan, schema);
307    let risk = classify_risk(plan, &impact, &validation, context);
308    let mut warnings = warnings_for(plan, context);
309    // 0.8.0 — schema-aware relation warnings. Kept here (not in
310    // `warnings_for`) so `warnings_for`'s signature stays a pure
311    // function of (plan, context); the relation warnings genuinely
312    // need the schema to check the target model for PII fields.
313    warnings.extend(relation_warnings_for(plan, schema, context));
314    Ok(PlanReview {
315        plan: plan.clone(),
316        impact,
317        risk,
318        warnings,
319        validation,
320    })
321}
322
323/// Warnings that depend on the *current schema* — specifically, whether
324/// the relation's target model contains fields flagged as PII under the
325/// project's context. Every bullet has a concrete trigger (the target
326/// model, and the PII columns that would become linkable).
327fn relation_warnings_for(
328    plan: &Plan,
329    schema: &Schema,
330    context: Option<&ContextConfig>,
331) -> Vec<String> {
332    let mut out: Vec<String> = Vec::new();
333    let pii: Vec<&str> = context.map(|c| c.pii_fields()).unwrap_or_default();
334    for step in &plan.steps {
335        let Primitive::AddRelation(r) = step else {
336            continue;
337        };
338        out.push(format!(
339            "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.",
340            r.from, r.via, r.to,
341        ));
342        if !pii.is_empty() {
343            // Only fire the PII warning when the target model actually
344            // has PII columns under the current context.
345            if let Some(target) = schema.models.iter().find(|m| m.name == r.to) {
346                let pii_hits: Vec<&str> = target
347                    .fields
348                    .iter()
349                    .filter_map(|f| pii.iter().copied().find(|p| *p == f.name))
350                    .collect();
351                if !pii_hits.is_empty() {
352                    out.push(format!(
353                        "Linking `{}` to `{}` creates a path to personally-identifying fields on the target ({}). Review GDPR minimisation / purpose-limitation before applying.",
354                        r.from,
355                        r.to,
356                        pii_hits.join(", "),
357                    ));
358                }
359            }
360        }
361    }
362    out
363}
364
365/// Parse JSON into either a [`PlanDocument`] or a raw [`Plan`].
366///
367/// The reader tries the richer [`PlanDocument`] first (because that's
368/// what `rustio ai plan --save` emits). On failure it tries the raw
369/// [`Plan`] shape. Only if *both* attempts fail do we surface an
370/// error, so a simple `Plan` JSON piped in from another tool is
371/// accepted transparently.
372pub fn load_plan(json: &str) -> Result<LoadedPlan, ReviewError> {
373    // Try the document shape first. `deny_unknown_fields` on
374    // `PlanDocument` means this only succeeds for a real document.
375    if let Ok(doc) = serde_json::from_str::<PlanDocument>(json) {
376        if doc.version != PLAN_DOCUMENT_VERSION {
377            return Err(ReviewError::UnknownVersion {
378                found: doc.version,
379                expected: PLAN_DOCUMENT_VERSION,
380            });
381        }
382        return Ok(LoadedPlan::Document(doc));
383    }
384    // Then try a raw Plan.
385    match serde_json::from_str::<Plan>(json) {
386        Ok(plan) => Ok(LoadedPlan::RawPlan(plan)),
387        Err(e) => Err(ReviewError::Parse(e.to_string())),
388    }
389}
390
391/// Compute the impact counts for a plan against a given schema. Pure
392/// and cheap — no allocation beyond the returned struct.
393pub fn compute_impact(plan: &Plan, schema: &Schema) -> PlanImpact {
394    let mut out = PlanImpact::default();
395    for step in &plan.steps {
396        match step {
397            Primitive::AddField(_) => out.adds_fields += 1,
398            Primitive::RemoveField(_) => {
399                out.removes_fields += 1;
400                out.destructive = true;
401            }
402            Primitive::RenameField(_) | Primitive::RenameModel(_) => out.renames += 1,
403            Primitive::ChangeFieldType(_) => out.type_changes += 1,
404            Primitive::ChangeFieldNullability(_) => out.nullability_changes += 1,
405            Primitive::RemoveModel(_) | Primitive::RemoveRelation(_) => {
406                out.destructive = true;
407            }
408            _ => {}
409        }
410        if touches_core_model(step, schema) {
411            out.touches_core_models = true;
412        }
413    }
414    out
415}
416
417/// Classify a plan's overall risk.
418///
419/// Rules (conservative — bump up, never down):
420///
421/// - If the plan fails validation → [`RiskLevel::Critical`].
422/// - If any step targets a core model → [`RiskLevel::Critical`].
423/// - If any step is developer-only (shouldn't happen from the planner
424///   but is possible from a hand-edited document) → [`RiskLevel::Critical`].
425/// - Otherwise take the max per-step risk, with one combinator: a
426///   plan that mixes destructive and constructive steps bumps to at
427///   least [`RiskLevel::High`].
428pub fn classify_risk(
429    plan: &Plan,
430    impact: &PlanImpact,
431    validation: &ValidationOutcome,
432    context: Option<&ContextConfig>,
433) -> RiskLevel {
434    if !validation.is_valid() {
435        return RiskLevel::Critical;
436    }
437    if impact.touches_core_models {
438        return RiskLevel::Critical;
439    }
440    if plan.steps.iter().any(|s| s.is_developer_only()) {
441        return RiskLevel::Critical;
442    }
443
444    // Context-aware escalation: destructive primitives on a field
445    // flagged as PII under the current project context are Critical,
446    // regardless of what the structural rules would score.
447    if let Some(ctx) = context {
448        let pii = ctx.pii_fields();
449        for step in &plan.steps {
450            match step {
451                Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => {
452                    return RiskLevel::Critical;
453                }
454                Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => {
455                    return RiskLevel::Critical;
456                }
457                Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => {
458                    return RiskLevel::Critical;
459                }
460                _ => {}
461            }
462        }
463    }
464
465    let mut max = RiskLevel::Low;
466    for step in &plan.steps {
467        let r = per_step_risk(step);
468        if r > max {
469            max = r;
470        }
471    }
472    // Mixing add + remove in one plan is its own footgun — bump.
473    let mixes_add_and_remove = impact.adds_fields > 0 && impact.removes_fields > 0;
474    if mixes_add_and_remove && max < RiskLevel::High {
475        max = RiskLevel::High;
476    }
477    max
478}
479
480/// Deterministic warnings derived strictly from the plan. Never
481/// speculative — every bullet in the output has a concrete trigger.
482///
483/// `context`, when present, surfaces the extra warnings the review
484/// layer owes an operator under real-world constraints: GDPR,
485/// industry conventions, country-specific PII. Without context the
486/// output is the 0.5.x set — nothing changes for projects that
487/// haven't opted in.
488pub fn warnings_for(plan: &Plan, context: Option<&ContextConfig>) -> Vec<String> {
489    use crate::ai::OnDelete;
490    let mut out: Vec<String> = Vec::new();
491    let mut has_remove = false;
492    let mut has_rename_model = false;
493    let mut has_rename_field = false;
494    let mut has_type_change = false;
495    let mut has_require = false;
496    let mut has_remove_model = false;
497    let mut has_dev_only = false;
498
499    for step in &plan.steps {
500        match step {
501            Primitive::RemoveField(_) => has_remove = true,
502            Primitive::RenameModel(_) => has_rename_model = true,
503            Primitive::RenameField(_) => has_rename_field = true,
504            Primitive::ChangeFieldType(_) => has_type_change = true,
505            Primitive::ChangeFieldNullability(c) if !c.nullable => has_require = true,
506            Primitive::RemoveModel(_) => has_remove_model = true,
507            // 0.9.0 — FK-specific warnings. Each bullet is justified by
508            // the policy on the primitive; nothing speculative.
509            Primitive::AddRelation(r) => {
510                if r.required {
511                    out.push(format!(
512                        "Relation `{model}.{via}` → `{to}` is required (NOT NULL FK). \
513                         Existing rows with no matching parent will prevent the \
514                         migration; use `rustio migrate add-fks --write` to retrofit \
515                         via recreate-table instead of ALTER TABLE.",
516                        model = r.from,
517                        via = r.via,
518                        to = r.to,
519                    ));
520                }
521                if matches!(r.on_delete, OnDelete::Cascade) {
522                    out.push(format!(
523                        "Relation `{model}.{via}` uses ON DELETE CASCADE: deleting a \
524                         single `{to}` row will delete every `{model}` row that \
525                         points at it. Review the blast radius before execution.",
526                        model = r.from,
527                        via = r.via,
528                        to = r.to,
529                    ));
530                }
531            }
532            _ => {}
533        }
534        if step.is_developer_only() {
535            has_dev_only = true;
536        }
537    }
538    if has_remove {
539        out.push("This plan removes a field. Existing data in that column may become inaccessible after execution.".into());
540    }
541    if has_remove_model {
542        out.push("This plan removes a model. Every row, foreign-key reference, and admin route for that model will be dropped.".into());
543    }
544    if has_rename_model {
545        out.push("This plan renames a model. Downstream code, admin URLs, and external integrations that hard-code the old name will break.".into());
546    }
547    if has_rename_field {
548        out.push("This plan renames a field. Queries, serialised payloads, and UI references using the old name will break.".into());
549    }
550    if has_require {
551        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());
552    }
553    if has_type_change {
554        out.push("This plan changes a field's type. The executor may refuse conversions it considers lossy.".into());
555    }
556    if has_type_change || has_require {
557        // Both triggers force a SQLite recreate-table migration.
558        out.push("This operation rewrites the entire table. Large tables may cause downtime during execution.".into());
559    }
560    if plan.steps.len() > 1 {
561        out.push(format!(
562            "This plan performs {n} operations. Review each step individually.",
563            n = plan.steps.len(),
564        ));
565    }
566    if has_dev_only {
567        out.push("This plan contains a developer-only primitive. It must never be executed from an AI pipeline.".into());
568    }
569
570    // Context-aware warnings. Each bullet is justified by the
571    // combination of (plan, context); nothing speculative.
572    if let Some(ctx) = context {
573        let pii = ctx.pii_fields();
574        for step in &plan.steps {
575            match step {
576                Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => {
577                    out.push(format!(
578                        "Field `{}.{}` is considered sensitive personal data under the project's context{}. Removing it is irreversible — review retention obligations first.",
579                        r.model,
580                        r.field,
581                        describe_context(ctx),
582                    ));
583                }
584                Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => {
585                    out.push(format!(
586                        "Field `{}.{}` is sensitive personal data; renaming it invalidates any existing access-log / audit trail keyed on the old name.",
587                        r.model, r.from,
588                    ));
589                }
590                Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => {
591                    out.push(format!(
592                        "Field `{}.{}` is sensitive personal data; type changes may affect hashing, masking, or retention pipelines keyed on its storage shape.",
593                        c.model, c.field,
594                    ));
595                }
596                _ => {}
597            }
598        }
599        // Industry-convention removals: warn if a plan removes a field
600        // the industry schema flags as a standard convention.
601        if let Some(schema) = ctx.industry_schema() {
602            for step in &plan.steps {
603                if let Primitive::RemoveField(r) = step {
604                    if schema.required_fields.iter().any(|f| f == &r.field) {
605                        out.push(format!(
606                            "Field `{}.{}` is a standard convention for the `{}` industry. Removing it will break downstream integrations that assume it exists.",
607                            r.model,
608                            r.field,
609                            ctx.industry.as_deref().unwrap_or(""),
610                        ));
611                    }
612                }
613            }
614        }
615    }
616
617    out
618}
619
620/// One-line description of the active context pieces. Used by warning
621/// messages that want to cite the reason they fired.
622fn describe_context(ctx: &ContextConfig) -> String {
623    let mut parts: Vec<String> = Vec::new();
624    if let Some(c) = &ctx.country {
625        parts.push(format!("country={c}"));
626    }
627    if let Some(i) = &ctx.industry {
628        parts.push(format!("industry={i}"));
629    }
630    if ctx.requires_gdpr() {
631        parts.push("GDPR".to_string());
632    }
633    if parts.is_empty() {
634        String::new()
635    } else {
636        format!(" ({})", parts.join(", "))
637    }
638}
639
640/// Render a [`PlanReview`] as an operator-friendly summary.
641///
642/// Output is a single text block — no colour, no fancy formatting,
643/// no `Debug` dumps. Designed to fit in a terminal window, a code
644/// review comment, or a Slack message.
645pub fn render_review_human(review: &PlanReview, header: Option<&ReviewHeader>) -> String {
646    let mut out = String::new();
647    out.push_str("Plan review\n");
648    if let Some(h) = header {
649        if let Some(p) = &h.prompt {
650            out.push_str(&format!("\nPrompt:\n  {p}\n"));
651        }
652        if let Some(e) = &h.explanation {
653            out.push_str(&format!("\nExplanation:\n  {e}\n"));
654        }
655        if let Some(src) = &h.source {
656            out.push_str(&format!("\nSource:\n  {src}\n"));
657        }
658    }
659    out.push_str(&format!("\nRisk:\n  {}\n", review.risk.as_str()));
660    out.push_str("\nImpact:\n");
661    for line in render_impact_lines(&review.impact) {
662        out.push_str("  - ");
663        out.push_str(&line);
664        out.push('\n');
665    }
666    out.push_str("\nPlanned changes:\n");
667    if review.plan.steps.is_empty() {
668        out.push_str("  - (none)\n");
669    } else {
670        for step in &review.plan.steps {
671            out.push_str("  - ");
672            out.push_str(&summarise_primitive(step));
673            out.push('\n');
674        }
675    }
676    out.push_str("\nValidation:\n");
677    match &review.validation {
678        ValidationOutcome::Valid => out.push_str("  - Passes against the current schema.\n"),
679        ValidationOutcome::Invalid { step, reason } => {
680            out.push_str(&format!(
681                "  - FAILS at step {step}: {reason}\n",
682                step = step,
683                reason = reason,
684            ));
685            out.push_str("  - The plan is stale or invalid for the current schema. Regenerate it before executing.\n");
686        }
687    }
688    out.push_str("\nWarnings:\n");
689    if review.warnings.is_empty() {
690        out.push_str("  - None\n");
691    } else {
692        for w in &review.warnings {
693            out.push_str("  - ");
694            out.push_str(w);
695            out.push('\n');
696        }
697    }
698    out
699}
700
701/// Optional context the CLI passes to the human renderer (the review
702/// engine itself doesn't see prompt / explanation — they live on the
703/// enclosing document).
704#[derive(Debug, Default, Clone)]
705pub struct ReviewHeader {
706    pub prompt: Option<String>,
707    pub explanation: Option<String>,
708    pub source: Option<String>,
709}
710
711/// Serialise a [`PlanDocument`] to deterministic, pretty-printed JSON
712/// with a trailing newline. Matches the convention
713/// `Schema::to_pretty_json` uses so both artefacts look uniform under
714/// review.
715pub fn render_plan_document_json(doc: &PlanDocument) -> Result<String, ReviewError> {
716    let mut out =
717        serde_json::to_string_pretty(doc).map_err(|e| ReviewError::Parse(e.to_string()))?;
718    out.push('\n');
719    Ok(out)
720}
721
722// ---------------------------------------------------------------------------
723// Internals
724// ---------------------------------------------------------------------------
725
726/// Simulate a plan against a schema copy (same logic `Plan::validate`
727/// uses internally). Returns the step index + error if validation
728/// stops, so the review can point at exactly which step is stale.
729fn simulate_plan(plan: &Plan, schema: &Schema) -> Result<(), (usize, PrimitiveError)> {
730    let mut state = schema.clone();
731    for (idx, step) in plan.steps.iter().enumerate() {
732        if step.is_developer_only() {
733            return Err((
734                idx,
735                PrimitiveError::DeveloperOnlyNotAllowedInPlan { op: step.op_name() },
736            ));
737        }
738        if let Err(e) = super::validate_primitive(step) {
739            return Err((idx, e));
740        }
741        if let Err(e) = validate_against(step, &state) {
742            return Err((idx, e));
743        }
744        apply_shadow_for_review(step, &mut state);
745    }
746    Ok(())
747}
748
749/// Shadow-apply a primitive to an in-memory schema copy. Mirrors the
750/// logic in `ai.rs::apply_shadow` but isn't re-exported, so we keep a
751/// tiny local copy rather than widening the crate's private surface.
752/// Safe to diverge? No — the point of the review is to model the
753/// same transitions the executor will. Keep this list in sync.
754fn apply_shadow_for_review(p: &Primitive, schema: &mut Schema) {
755    use crate::schema::{SchemaField, SchemaRelation};
756    match p {
757        Primitive::AddModel(m) => {
758            let mut fields: Vec<SchemaField> = m
759                .fields
760                .iter()
761                .map(|f| SchemaField {
762                    name: f.name.clone(),
763                    ty: f.ty.clone(),
764                    nullable: f.nullable,
765                    editable: f.editable,
766                    relation: None,
767                })
768                .collect();
769            fields.sort_by(|a, b| a.name.cmp(&b.name));
770            schema.models.push(SchemaModel {
771                name: m.name.clone(),
772                table: m.table.clone(),
773                admin_name: m.table.clone(),
774                display_name: m.name.clone(),
775                singular_name: m.name.clone(),
776                fields,
777                relations: Vec::new(),
778                core: false,
779            });
780            schema.models.sort_by(|a, b| a.name.cmp(&b.name));
781        }
782        Primitive::RemoveModel(m) => schema.models.retain(|x| x.name != m.name),
783        Primitive::AddField(af) => {
784            if let Some(model) = schema.models.iter_mut().find(|m| m.name == af.model) {
785                model.fields.push(SchemaField {
786                    name: af.field.name.clone(),
787                    ty: af.field.ty.clone(),
788                    nullable: af.field.nullable,
789                    editable: af.field.editable,
790                    relation: None,
791                });
792                model.fields.sort_by(|a, b| a.name.cmp(&b.name));
793            }
794        }
795        Primitive::RemoveField(rf) => {
796            if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
797                model.fields.retain(|f| f.name != rf.field);
798            }
799        }
800        Primitive::RenameModel(rm) => {
801            if let Some(model) = schema.models.iter_mut().find(|m| m.name == rm.from) {
802                model.name = rm.to.clone();
803                model.singular_name = rm.to.clone();
804            }
805            schema.models.sort_by(|a, b| a.name.cmp(&b.name));
806        }
807        Primitive::RenameField(rf) => {
808            if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
809                if let Some(field) = model.fields.iter_mut().find(|f| f.name == rf.from) {
810                    field.name = rf.to.clone();
811                }
812                model.fields.sort_by(|a, b| a.name.cmp(&b.name));
813            }
814        }
815        Primitive::ChangeFieldType(c) => {
816            if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
817                if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
818                    field.ty = c.new_type.clone();
819                }
820            }
821        }
822        Primitive::ChangeFieldNullability(c) => {
823            if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
824                if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
825                    field.nullable = c.nullable;
826                }
827            }
828        }
829        Primitive::AddRelation(r) => {
830            use crate::schema::{Relation, RelationKind};
831            if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
832                model.relations.push(SchemaRelation {
833                    kind: format!("{:?}", r.kind).to_lowercase(),
834                    to: r.to.clone(),
835                    via: r.via.clone(),
836                });
837                // Materialise the FK column at the field level too —
838                // 0.8.0 belongs_to adds a `<via>` i64 column. has_many
839                // is virtual and adds no column.
840                if matches!(r.kind, RelationKind::BelongsTo)
841                    && !model.fields.iter().any(|f| f.name == r.via)
842                {
843                    model.fields.push(SchemaField {
844                        name: r.via.clone(),
845                        ty: "i64".to_string(),
846                        nullable: !r.required,
847                        editable: true,
848                        relation: Some(Relation {
849                            model: r.to.clone(),
850                            field: "id".to_string(),
851                            kind: RelationKind::BelongsTo,
852                            display_field: None,
853                            required: Some(r.required),
854                            on_delete: Some(r.on_delete.as_str().to_string()),
855                        }),
856                    });
857                    model.fields.sort_by(|a, b| a.name.cmp(&b.name));
858                }
859            }
860        }
861        Primitive::RemoveRelation(r) => {
862            if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
863                model.relations.retain(|rel| rel.via != r.via);
864            }
865        }
866        Primitive::UpdateAdmin(_) | Primitive::CreateMigration(_) => {}
867    }
868}
869
870/// Does this primitive target a model that is flagged `core` in the
871/// schema? Used by [`compute_impact`] to set `touches_core_models`
872/// and by [`classify_risk`] to bump to Critical.
873fn touches_core_model(p: &Primitive, schema: &Schema) -> bool {
874    let target = match p {
875        Primitive::AddField(a) => Some(a.model.as_str()),
876        Primitive::RemoveField(r) => Some(r.model.as_str()),
877        Primitive::RenameField(r) => Some(r.model.as_str()),
878        Primitive::ChangeFieldType(c) => Some(c.model.as_str()),
879        Primitive::ChangeFieldNullability(c) => Some(c.model.as_str()),
880        Primitive::UpdateAdmin(u) => Some(u.model.as_str()),
881        Primitive::RenameModel(r) => Some(r.from.as_str()),
882        Primitive::RemoveModel(m) => Some(m.name.as_str()),
883        Primitive::AddRelation(r) => Some(r.from.as_str()),
884        Primitive::RemoveRelation(r) => Some(r.from.as_str()),
885        // AddModel creates a new (necessarily non-core) model.
886        Primitive::AddModel(_) | Primitive::CreateMigration(_) => None,
887    };
888    let Some(name) = target else { return false };
889    schema.models.iter().any(|m| m.name == name && m.core)
890}
891
892fn per_step_risk(p: &Primitive) -> RiskLevel {
893    use crate::ai::OnDelete;
894    match p {
895        // Safe additions
896        Primitive::AddField(a) if a.field.nullable => RiskLevel::Low,
897        Primitive::AddField(_) => RiskLevel::Low,
898        // 0.9.0 — AddRelation risk depends on the FK policy.
899        // `required + cascade` is the most dangerous combination: a NOT
900        // NULL FK blocks inserts with no parent, and a cascade delete
901        // sweeps children out silently. `required` alone is Medium
902        // (insert-time friction). `cascade` alone is Medium (one bad
903        // DELETE can remove many rows). Everything else stays Low.
904        Primitive::AddRelation(r) => match (r.required, r.on_delete) {
905            (true, OnDelete::Cascade) => RiskLevel::High,
906            (true, _) | (_, OnDelete::Cascade) => RiskLevel::Medium,
907            _ => RiskLevel::Low,
908        },
909        Primitive::AddModel(_) => RiskLevel::Low,
910        Primitive::UpdateAdmin(_) => RiskLevel::Low,
911        // Flipping to nullable is reversible and safe; to required is not.
912        Primitive::ChangeFieldNullability(c) if c.nullable => RiskLevel::Low,
913        // Tightening nullable → required is High (0.5.3): the executor
914        // will COALESCE existing NULLs with a type default at write
915        // time, which is acceptable data loss *if* the reviewer has
916        // consented to it. Conservative bump.
917        Primitive::ChangeFieldNullability(_) => RiskLevel::High,
918        // Data-preserving but noisy
919        Primitive::RenameField(_) | Primitive::RenameModel(_) | Primitive::ChangeFieldType(_) => {
920            RiskLevel::Medium
921        }
922        // Destructive
923        Primitive::RemoveField(_) | Primitive::RemoveModel(_) | Primitive::RemoveRelation(_) => {
924            RiskLevel::High
925        }
926        // Shouldn't be in a plan at all — reviewer must refuse.
927        Primitive::CreateMigration(_) => RiskLevel::Critical,
928    }
929}
930
931/// One-line description for the human review. Matches the style of
932/// `planner::render_plan_human` but in past-tense-summary form.
933fn summarise_primitive(p: &Primitive) -> String {
934    match p {
935        Primitive::AddField(a) => format!(
936            "Add field \"{}\" ({}{}) to model \"{}\"",
937            a.field.name,
938            a.field.ty,
939            if a.field.nullable { ", nullable" } else { "" },
940            a.model,
941        ),
942        Primitive::RemoveField(r) => {
943            format!("Remove field \"{}\" from model \"{}\"", r.field, r.model)
944        }
945        Primitive::RenameField(r) => {
946            format!("Rename field \"{}.{}\" to \"{}\"", r.model, r.from, r.to)
947        }
948        Primitive::RenameModel(r) => {
949            format!("Rename model \"{}\" to \"{}\"", r.from, r.to)
950        }
951        Primitive::ChangeFieldType(c) => format!(
952            "Change type of \"{}.{}\" to {}",
953            c.model, c.field, c.new_type
954        ),
955        Primitive::ChangeFieldNullability(c) => format!(
956            "Mark \"{}.{}\" as {}",
957            c.model,
958            c.field,
959            if c.nullable { "nullable" } else { "required" },
960        ),
961        Primitive::AddModel(m) => format!(
962            "Add model \"{}\" ({} field{})",
963            m.name,
964            m.fields.len(),
965            if m.fields.len() == 1 { "" } else { "s" }
966        ),
967        Primitive::RemoveModel(m) => format!("Remove model \"{}\"", m.name),
968        Primitive::AddRelation(r) => format!(
969            "Add relation {:?}: {}.{} -> {}",
970            r.kind, r.from, r.via, r.to
971        ),
972        Primitive::RemoveRelation(r) => {
973            format!("Remove relation \"{}.{}\"", r.from, r.via)
974        }
975        Primitive::UpdateAdmin(u) => format!(
976            "Update admin attribute \"{}.{}\".{}",
977            u.model, u.field, u.attr
978        ),
979        Primitive::CreateMigration(m) => format!("[dev-only] create_migration \"{}\"", m.name),
980    }
981}
982
983/// Expand an impact struct into the bullet list the human renderer
984/// uses. Each bullet is deterministic and self-describing.
985fn render_impact_lines(i: &PlanImpact) -> Vec<String> {
986    let mut lines: Vec<String> = Vec::new();
987    push_count_line(&mut lines, "Add", i.adds_fields, "field");
988    push_count_line(&mut lines, "Remove", i.removes_fields, "field");
989    push_count_line(&mut lines, "Rename", i.renames, "item");
990    push_count_line(&mut lines, "Type change", i.type_changes, "field");
991    push_count_line(
992        &mut lines,
993        "Nullability change",
994        i.nullability_changes,
995        "field",
996    );
997    if i.destructive {
998        lines.push("Includes at least one destructive step".into());
999    } else {
1000        lines.push("No destructive changes".into());
1001    }
1002    if i.touches_core_models {
1003        lines.push("Touches a core model — review carefully".into());
1004    } else {
1005        lines.push("Does not touch core models".into());
1006    }
1007    lines
1008}
1009
1010fn push_count_line(out: &mut Vec<String>, verb: &str, n: usize, unit: &str) {
1011    if n == 0 {
1012        return;
1013    }
1014    out.push(format!(
1015        "{verb} {n} {unit}{s}",
1016        s = if n == 1 { "" } else { "s" }
1017    ));
1018}