rustio-core 2.0.0

Runtime core for RustIO: HTTP server, router, middleware, ORM, admin, and migrations.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
//! Plan Review Layer — 0.5.1.
//!
//! The reviewable, risk-scored boundary between the AI planner (0.5.0)
//! and the (future) executor. Its single responsibility is to let a
//! human operator answer the question:
//!
//! > "I understand exactly what the AI wants to do, how risky it is,
//! >  and whether I should allow it."
//!
//! Nothing in this module touches the filesystem, the database, the
//! schema on disk, or emits SQL. It inspects an in-memory [`Plan`]
//! against an in-memory [`Schema`] and returns a structured report.
//!
//! ## What the layer provides
//!
//! - [`PlanDocument`] — a reviewable, serialisable envelope around a
//!   [`Plan`], carrying the prompt, the explanation the planner gave,
//!   the computed risk + impact, and a timestamp. Versioned (see
//!   [`PLAN_DOCUMENT_VERSION`]) so older-format documents are rejected
//!   rather than silently misread.
//! - [`review_plan`] — takes a plan and the current schema and
//!   produces a [`PlanReview`]: validation outcome, risk level, impact
//!   counts, and a deterministic list of warnings.
//! - [`load_plan`] — accepts either a full [`PlanDocument`] or a raw
//!   [`Plan`] JSON and tells the caller which it read, so CLI tools
//!   can normalise both shapes without guessing.
//! - Renderers for both a stable JSON output and an operator-friendly
//!   human summary.
//!
//! ## Determinism
//!
//! Risk classification, impact counting, and warning generation are
//! **deterministic**: the same `(Plan, Schema)` always yields the
//! same review. The only non-deterministic field anywhere in the
//! layer is [`PlanDocument::created_at`]; for tests, use
//! [`build_plan_document_with_timestamp`] to pin it.
//!
//! ## Safety posture
//!
//! Risk classification is *conservative*. When in doubt the layer
//! bumps up, never down. `Critical` is reserved for situations a
//! reviewer must refuse by default: plans that touch core models,
//! plans that fail validation, plans containing developer-only ops.
//!
//! ## What this module does NOT do
//!
//! - It does not parse user prompts (that is the planner's job).
//! - It does not modify the plan to "fix" it.
//! - It does not emit SQL, write files, or open databases.
//! - It does not call external services.

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};

/// Version tag written into every [`PlanDocument`]. Bumped **only** on
/// a breaking change to the document shape — adding an optional field
/// is a minor change that the current reader handles via serde's
/// defaults. Parsers reject any document whose `version` doesn't match
/// this constant, so a future executor can trust the shape it reads.
pub const PLAN_DOCUMENT_VERSION: u32 = 1;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/// Severity class used by the review engine. Ordered so callers can
/// compare (`risk >= RiskLevel::High`) and take the max across steps.
///
/// The variants are a small, deliberately-closed enum; adding one is
/// a breaking change because reviewers rely on the four tiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskLevel {
    /// Reversible, non-destructive. e.g. an `AddField` for a nullable
    /// column, or flipping a field to nullable.
    Low,
    /// Data-preserving but disruptive. e.g. a rename, or a type change
    /// the executor will verify against data.
    Medium,
    /// Destructive or disruptive enough that a reviewer should pause.
    /// e.g. `RemoveField`, or a plan that mixes destructive and
    /// constructive steps.
    High,
    /// Must not execute without a reviewer overriding deliberately.
    /// Reached by: touching a core model, failing validation, or
    /// encountering a developer-only primitive in a plan.
    Critical,
}

impl RiskLevel {
    pub fn as_str(self) -> &'static str {
        match self {
            RiskLevel::Low => "Low",
            RiskLevel::Medium => "Medium",
            RiskLevel::High => "High",
            RiskLevel::Critical => "Critical",
        }
    }
}

/// Aggregate counts of what a plan changes. Used by the CLI summary
/// and as an input to the risk classifier. Every field is non-negative
/// and derived mechanically from the plan — no fuzzy heuristics.
#[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,
    /// `true` if any step's `model` points at a model flagged `core`
    /// in the supplied schema. Core models (e.g. `User`) are
    /// infrastructure — modifying them from an AI plan is never
    /// acceptable without a reviewer's explicit override.
    pub touches_core_models: bool,
    /// `true` if the plan contains any destructive primitive
    /// (`RemoveField`, `RemoveModel`, `RemoveRelation`). Distinct
    /// from the per-primitive counts so consumers can branch on
    /// "is anything destructive" without summing four fields.
    pub destructive: bool,
}

/// Serialisable, reviewable envelope around a validated [`Plan`].
///
/// The document carries every piece of context a reviewer needs to
/// decide yes/no without re-running the planner:
/// what the user asked for, the planner's one-paragraph explanation,
/// the computed impact + risk, and the plan itself.
///
/// `#[serde(deny_unknown_fields)]` is load-bearing: it prevents a
/// future executor from silently reading a field we never meant to
/// populate, and catches copy-paste errors in reviewer-authored
/// documents.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanDocument {
    pub version: u32,
    /// RFC 3339 UTC timestamp. Informational only — not used by the
    /// review engine. Stored as a string so the format is locked down
    /// regardless of chrono version.
    pub created_at: String,
    pub prompt: String,
    pub explanation: String,
    pub risk: RiskLevel,
    pub impact: PlanImpact,
    pub plan: Plan,
}

/// Output of a `load_plan` call. Lets the CLI distinguish "user
/// handed me a raw plan" from "user handed me a reviewed document"
/// and print a matching status line.
#[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,
        }
    }
}

/// Result of [`review_plan`]. A review is always produced, even for
/// invalid plans — callers need to *see* the invalidity reason
/// without a separate error path.
#[derive(Debug, Clone, PartialEq)]
pub struct PlanReview {
    pub plan: Plan,
    pub impact: PlanImpact,
    pub risk: RiskLevel,
    pub warnings: Vec<String>,
    pub validation: ValidationOutcome,
}

/// Did the plan survive validation against the supplied schema?
///
/// Invalid ≠ malformed: a plan may be structurally fine but stale
/// (the schema it targeted has moved on). The variant carries the
/// step index + reason so operators can pinpoint which primitive is
/// now invalid.
#[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)
    }
}

/// Reasons a review layer operation can fail. Parse errors and
/// version mismatches are the common cases; structural failures
/// (e.g. `build_plan_document` handed a plan that somehow fails
/// its own validation) are included for defence in depth.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum ReviewError {
    /// JSON didn't match any known shape (document or raw plan), or
    /// one of the shapes failed serde parsing.
    Parse(String),
    /// The document's `version` field didn't match
    /// [`PLAN_DOCUMENT_VERSION`]. Loud refusal rather than silent
    /// upgrade — the document shape is part of the API surface.
    UnknownVersion { found: u32, expected: u32 },
    /// A plan supplied to `build_plan_document` failed its own
    /// internal validation. This should only happen if the planner
    /// contract was violated upstream.
    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 {}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Build a [`PlanDocument`] from a fresh planner result. Uses the
/// current UTC wall-clock for `created_at`.
///
/// The document is validated against `schema` before being returned:
/// an invalid plan surfaces as [`ReviewError::InvalidPlan`] rather
/// than producing a document the reviewer might trust.
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)
}

/// Same as [`build_plan_document`] but accepts an explicit timestamp.
/// Tests use a pinned value so snapshot comparisons stay stable;
/// callers with their own clock abstraction (e.g. a CI runner that
/// freezes time) can plumb it through here.
pub fn build_plan_document_with_timestamp(
    schema: &Schema,
    prompt: &str,
    result: &PlanResult,
    timestamp: DateTime<Utc>,
    context: Option<&ContextConfig>,
) -> Result<PlanDocument, ReviewError> {
    // Defence in depth: the planner already calls Plan::validate, but
    // an invalid plan sneaking into a saved document would be a
    // catastrophic review-layer failure. Re-check here.
    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(),
    })
}

/// Review a plan against a schema without executing anything. Returns
/// the full report even if the plan is invalid — the caller decides
/// how to present that to the user.
///
/// `context`, when present, escalates risk and adds context-aware
/// warnings (e.g. removing a personnummer field under `country=SE`
/// becomes Critical with a GDPR notice). Callers who don't have a
/// project context pass `None` — the review then behaves exactly
/// as in 0.5.x.
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);
    // 0.8.0 — schema-aware relation warnings. Kept here (not in
    // `warnings_for`) so `warnings_for`'s signature stays a pure
    // function of (plan, context); the relation warnings genuinely
    // need the schema to check the target model for PII fields.
    warnings.extend(relation_warnings_for(plan, schema, context));
    Ok(PlanReview {
        plan: plan.clone(),
        impact,
        risk,
        warnings,
        validation,
    })
}

/// Warnings that depend on the *current schema* — specifically, whether
/// the relation's target model contains fields flagged as PII under the
/// project's context. Every bullet has a concrete trigger (the target
/// model, and the PII columns that would become linkable).
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() {
            // Only fire the PII warning when the target model actually
            // has PII columns under the current context.
            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
}

/// Parse JSON into either a [`PlanDocument`] or a raw [`Plan`].
///
/// The reader tries the richer [`PlanDocument`] first (because that's
/// what `rustio ai plan --save` emits). On failure it tries the raw
/// [`Plan`] shape. Only if *both* attempts fail do we surface an
/// error, so a simple `Plan` JSON piped in from another tool is
/// accepted transparently.
pub fn load_plan(json: &str) -> Result<LoadedPlan, ReviewError> {
    // Try the document shape first. `deny_unknown_fields` on
    // `PlanDocument` means this only succeeds for a real document.
    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));
    }
    // Then try a raw Plan.
    match serde_json::from_str::<Plan>(json) {
        Ok(plan) => Ok(LoadedPlan::RawPlan(plan)),
        Err(e) => Err(ReviewError::Parse(e.to_string())),
    }
}

/// Compute the impact counts for a plan against a given schema. Pure
/// and cheap — no allocation beyond the returned struct.
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
}

/// Classify a plan's overall risk.
///
/// Rules (conservative — bump up, never down):
///
/// - If the plan fails validation → [`RiskLevel::Critical`].
/// - If any step targets a core model → [`RiskLevel::Critical`].
/// - If any step is developer-only (shouldn't happen from the planner
///   but is possible from a hand-edited document) → [`RiskLevel::Critical`].
/// - Otherwise take the max per-step risk, with one combinator: a
///   plan that mixes destructive and constructive steps bumps to at
///   least [`RiskLevel::High`].
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;
    }

    // Context-aware escalation: destructive primitives on a field
    // flagged as PII under the current project context are Critical,
    // regardless of what the structural rules would score.
    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;
        }
    }
    // Mixing add + remove in one plan is its own footgun — bump.
    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
}

/// Deterministic warnings derived strictly from the plan. Never
/// speculative — every bullet in the output has a concrete trigger.
///
/// `context`, when present, surfaces the extra warnings the review
/// layer owes an operator under real-world constraints: GDPR,
/// industry conventions, country-specific PII. Without context the
/// output is the 0.5.x set — nothing changes for projects that
/// haven't opted in.
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,
            // 0.9.0 — FK-specific warnings. Each bullet is justified by
            // the policy on the primitive; nothing speculative.
            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 {
        // Both triggers force a SQLite recreate-table migration.
        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());
    }

    // Context-aware warnings. Each bullet is justified by the
    // combination of (plan, context); nothing speculative.
    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,
                    ));
                }
                _ => {}
            }
        }
        // Industry-convention removals: warn if a plan removes a field
        // the industry schema flags as a standard convention.
        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
}

/// One-line description of the active context pieces. Used by warning
/// messages that want to cite the reason they fired.
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(", "))
    }
}

/// Render a [`PlanReview`] as an operator-friendly summary.
///
/// Output is a single text block — no colour, no fancy formatting,
/// no `Debug` dumps. Designed to fit in a terminal window, a code
/// review comment, or a Slack message.
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
}

/// Optional context the CLI passes to the human renderer (the review
/// engine itself doesn't see prompt / explanation — they live on the
/// enclosing document).
#[derive(Debug, Default, Clone)]
pub struct ReviewHeader {
    pub prompt: Option<String>,
    pub explanation: Option<String>,
    pub source: Option<String>,
}

/// Serialise a [`PlanDocument`] to deterministic, pretty-printed JSON
/// with a trailing newline. Matches the convention
/// `Schema::to_pretty_json` uses so both artefacts look uniform under
/// review.
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)
}

// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------

/// Simulate a plan against a schema copy (same logic `Plan::validate`
/// uses internally). Returns the step index + error if validation
/// stops, so the review can point at exactly which step is stale.
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(())
}

/// Shadow-apply a primitive to an in-memory schema copy. Mirrors the
/// logic in `ai.rs::apply_shadow` but isn't re-exported, so we keep a
/// tiny local copy rather than widening the crate's private surface.
/// Safe to diverge? No — the point of the review is to model the
/// same transitions the executor will. Keep this list in sync.
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(),
                });
                // Materialise the FK column at the field level too —
                // 0.8.0 belongs_to adds a `<via>` i64 column. has_many
                // is virtual and adds no column.
                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(_) => {}
    }
}

/// Does this primitive target a model that is flagged `core` in the
/// schema? Used by [`compute_impact`] to set `touches_core_models`
/// and by [`classify_risk`] to bump to Critical.
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()),
        // AddModel creates a new (necessarily non-core) model.
        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 {
        // Safe additions
        Primitive::AddField(a) if a.field.nullable => RiskLevel::Low,
        Primitive::AddField(_) => RiskLevel::Low,
        // 0.9.0 — AddRelation risk depends on the FK policy.
        // `required + cascade` is the most dangerous combination: a NOT
        // NULL FK blocks inserts with no parent, and a cascade delete
        // sweeps children out silently. `required` alone is Medium
        // (insert-time friction). `cascade` alone is Medium (one bad
        // DELETE can remove many rows). Everything else stays 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,
        // Flipping to nullable is reversible and safe; to required is not.
        Primitive::ChangeFieldNullability(c) if c.nullable => RiskLevel::Low,
        // Tightening nullable → required is High (0.5.3): the executor
        // will COALESCE existing NULLs with a type default at write
        // time, which is acceptable data loss *if* the reviewer has
        // consented to it. Conservative bump.
        Primitive::ChangeFieldNullability(_) => RiskLevel::High,
        // Data-preserving but noisy
        Primitive::RenameField(_) | Primitive::RenameModel(_) | Primitive::ChangeFieldType(_) => {
            RiskLevel::Medium
        }
        // Destructive
        Primitive::RemoveField(_) | Primitive::RemoveModel(_) | Primitive::RemoveRelation(_) => {
            RiskLevel::High
        }
        // Shouldn't be in a plan at all — reviewer must refuse.
        Primitive::CreateMigration(_) => RiskLevel::Critical,
    }
}

/// One-line description for the human review. Matches the style of
/// `planner::render_plan_human` but in past-tense-summary form.
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),
    }
}

/// Expand an impact struct into the bullet list the human renderer
/// uses. Each bullet is deterministic and self-describing.
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" }
    ));
}